import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import type {
  FeatureMeta,
  FeatureFilters,
  Bounds,
  HexagonData,
  PostcodeFeature,
  ViewChangeParams,
  ApiResponse,
} from '../types';
import {
  buildFilterString,
  apiUrl,
  assertOk,
  logNonAbortError,
  authHeaders,
  isAbortError,
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
import { getElectionVoteShareFeatureName } from '../lib/election-filter';
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';

/** Return the p-th percentile (0–100) from a sorted typed array via linear interpolation. */
function percentile(sorted: Float64Array, p: number): number {
  if (sorted.length === 0) return 0;
  if (sorted.length === 1) return sorted[0];
  const idx = (p / 100) * (sorted.length - 1);
  const lo = Math.floor(idx);
  const hi = Math.ceil(idx);
  if (lo === hi) return sorted[lo];
  return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
}

const DEBOUNCE_MS = 150;

interface UseMapDataOptions {
  filters: FeatureFilters;
  features: FeatureMeta[];
  viewFeature: string | null;
  activeFeature: string | null;
  pinnedFeature: string | null;
  filterRange?: [number, number] | null;
  travelTimeEntries: TravelTimeEntry[];
  /** Share-link code from the URL; appended to data fetches so the backend
   * grants bbox-scoped access for unlicensed recipients. */
  shareCode?: string;
}

function getFiniteNumber(value: unknown): number | null {
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

function valueInVisibleRange(
  value: number,
  minValue: number | null,
  maxValue: number | null,
  visibleRange: [number, number] | null
): number | null {
  if (!visibleRange) return value;

  const itemMin = minValue ?? value;
  const itemMax = maxValue ?? value;
  if (itemMax < visibleRange[0] || itemMin > visibleRange[1]) return null;
  return Math.max(visibleRange[0], Math.min(visibleRange[1], value));
}

export function useMapData({
  filters,
  features,
  viewFeature,
  activeFeature,
  pinnedFeature,
  filterRange = null,
  travelTimeEntries,
  shareCode,
}: UseMapDataOptions) {
  const [rawData, setRawData] = useState<HexagonData[]>([]);
  const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
  const [resolution, setResolution] = useState<number>(8);
  const [bounds, setBounds] = useState<Bounds | null>(null);
  const [visibleBounds, setVisibleBounds] = useState<Bounds | null>(null);
  const [loading, setLoading] = useState<boolean>(false);
  const [zoom, setZoom] = useState<number>(10);
  const [currentView, setCurrentView] = useState<{
    latitude: number;
    longitude: number;
    zoom: number;
  } | null>(null);
  const [currentVisibleView, setCurrentVisibleView] = useState<{
    latitude: number;
    longitude: number;
    zoom: number;
  } | null>(null);
  const [licenseRequired, setLicenseRequired] = useState(false);
  const [freeZone, setFreeZone] = useState<Bounds | null>(null);

  // Drag preview state
  const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
  const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
  const [dragDataKey, setDragDataKey] = useState<string>('');
  const dragFeatureRef = useRef<string | null>(null);
  const dragAbortRef = useRef<AbortController | null>(null);
  const activeFeatureRef = useRef<string | null>(null);
  const latestDataRequestKeyRef = useRef<string>('');
  const latestDragRequestKeyRef = useRef<string>('');

  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);
  const prevBoundsRef = useRef<string>('');

  const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
  const getBackendFeatureName = useCallback(
    (name: string) =>
      getSchoolBackendFeatureName(name) ??
      getSpecificCrimeFeatureName(name) ??
      getElectionVoteShareFeatureName(name) ??
      getEthnicityFeatureName(name) ??
      getPoiDistanceFeatureName(name) ??
      name,
    []
  );
  const dataViewFeature = useMemo(
    () => (viewFeature ? getBackendFeatureName(viewFeature) : null),
    [getBackendFeatureName, viewFeature]
  );
  const pinnedDataViewFeature = useMemo(
    () => (pinnedFeature ? getBackendFeatureName(pinnedFeature) : null),
    [getBackendFeatureName, pinnedFeature]
  );

  // Determine if the current viewFeature is an enum (for enum_dist param)
  const viewFeatureIsEnum = useMemo(
    () =>
      dataViewFeature ? features.find((f) => f.name === dataViewFeature)?.type === 'enum' : false,
    [dataViewFeature, features]
  );

  const buildFilterParam = useCallback(
    (): string => buildFilterString(filters, features),
    [filters, features]
  );
  const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);

  // Format: mode:slug[:best][:min:max]. For drag preview, the active travel
  // filter uses an unbounded range so rows with travel data stay visible.
  const buildTravelParam = useCallback(
    (excludeFieldKey?: string): string =>
      serializeTravelParam(travelTimeEntries, excludeFieldKey, true),
    [travelTimeEntries]
  );

  const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
  const filterStateKey = useMemo(
    () => `${filtersParam}|${travelParam}`,
    [filtersParam, travelParam]
  );
  const boundsParam = useMemo(
    () => (bounds ? `${bounds.south},${bounds.west},${bounds.north},${bounds.east}` : ''),
    [bounds]
  );
  const dataRequestKey = useMemo(
    () =>
      bounds
        ? [
            usePostcodeView ? 'postcodes' : 'hexagons',
            resolution,
            boundsParam,
            filtersParam,
            dataViewFeature ?? '',
            viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
            travelParam,
            shareCode ?? '',
          ].join('|')
        : '',
    [
      bounds,
      boundsParam,
      dataViewFeature,
      filtersParam,
      resolution,
      shareCode,
      travelParam,
      usePostcodeView,
      viewFeatureIsEnum,
    ]
  );
  const [loadedDataKey, setLoadedDataKey] = useState<string>('');
  const previousDragStateRef = useRef<{ activeFeature: string | null; filterStateKey: string }>({
    activeFeature: null,
    filterStateKey,
  });
  const resetPreviewScaleAfterSliderRef = useRef(false);
  const activeDragRequest = useMemo(() => {
    if (!activeFeature || !bounds) return null;

    const filtersStr = buildFilterString(filters, features, activeFeature);
    const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
    const isTravelTimeDrag = activeFeature.startsWith('tt_');
    const dataActiveFeature = getBackendFeatureName(activeFeature);
    const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
    // Travel time fields are computed from the travel param, not regular feature columns.
    // Sending a tt_* name as fields would cause a 400 (unknown field). Use empty string instead.
    const fieldsParam = isTravelTimeDrag ? '' : dataActiveFeature;
    const requestKey = [
      usePostcodeView ? 'postcodes' : 'hexagons',
      activeFeature,
      resolution,
      boundsStr,
      filtersStr,
      fieldsParam,
      dragTravelParam,
      viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
      shareCode ?? '',
    ].join('|');

    return { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey };
  }, [
    activeFeature,
    bounds,
    buildTravelParam,
    dataViewFeature,
    features,
    filters,
    getBackendFeatureName,
    resolution,
    shareCode,
    travelParam,
    usePostcodeView,
    viewFeatureIsEnum,
  ]);

  // Keep activeFeatureRef in sync
  useEffect(() => {
    activeFeatureRef.current = activeFeature;
  }, [activeFeature]);

  useEffect(() => {
    const previous = previousDragStateRef.current;
    if (!activeFeature && previous.activeFeature && previous.filterStateKey !== filterStateKey) {
      resetPreviewScaleAfterSliderRef.current = true;
    }
    previousDragStateRef.current = { activeFeature, filterStateKey };
  }, [activeFeature, filterStateKey]);

  useEffect(() => {
    if (activeFeature) return;
    latestDragRequestKeyRef.current = '';
    dragFeatureRef.current = null;
    setDragDataKey('');
    setDragHexData(null);
    setDragPostcodeData(null);
  }, [activeFeature]);

  // Drag prefetch: when activeFeature starts, fetch data excluding that filter.
  // For regular filters: excludes the filter from the filter string.
  // For travel time: excludes the time range from that entry's travel param segment.
  useEffect(() => {
    if (!activeFeature || !activeDragRequest) return;

    // Abort any in-flight previous drag fetch before starting a new one.
    if (dragAbortRef.current) dragAbortRef.current.abort();

    // Capture the controller locally so this effect's cleanup unambiguously
    // aborts THIS request's controller, even if `dragAbortRef.current` has
    // been swapped by a subsequent effect run.
    const controller = new AbortController();
    dragAbortRef.current = controller;
    const { signal } = controller;

    const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
    // Capture activeFeature in a local so the async .then() callback cannot
    // observe a stale-or-newer value via closure surprise.
    const effectActiveFeature = activeFeature;
    latestDragRequestKeyRef.current = requestKey;
    setDragDataKey('');
    dragFeatureRef.current = null;

    if (usePostcodeView) {
      const params = new URLSearchParams({ bounds: boundsStr });
      if (filtersStr) params.set('filters', filtersStr);
      params.set('fields', fieldsParam);
      if (dragTravelParam) params.set('travel', dragTravelParam);
      if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
      if (shareCode) params.set('share', shareCode);

      fetch(apiUrl('postcodes', params), authHeaders({ signal }))
        .then((res) => res.json())
        .then((json: { features: PostcodeFeature[] }) => {
          if (signal.aborted) return;
          if (latestDragRequestKeyRef.current !== requestKey) return;
          setDragPostcodeData(json.features);
          setDragHexData(null);
          setDragDataKey(requestKey);
          dragFeatureRef.current = effectActiveFeature;
        })
        .catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
    } else {
      const params = new URLSearchParams({
        resolution: resolution.toString(),
        bounds: boundsStr,
      });
      if (filtersStr) params.set('filters', filtersStr);
      params.set('fields', fieldsParam);
      if (dragTravelParam) params.set('travel', dragTravelParam);
      if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
      if (shareCode) params.set('share', shareCode);

      fetch(apiUrl('hexagons', params), authHeaders({ signal }))
        .then((res) => res.json())
        .then((json: ApiResponse) => {
          if (signal.aborted) return;
          if (latestDragRequestKeyRef.current !== requestKey) return;
          setDragHexData(json.features);
          setDragPostcodeData(null);
          setDragDataKey(requestKey);
          dragFeatureRef.current = effectActiveFeature;
        })
        .catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
    }

    return () => {
      // Abort the controller captured by THIS effect run rather than reading
      // from the ref (which may already have been replaced by a newer run).
      controller.abort();
      if (dragAbortRef.current === controller) {
        dragAbortRef.current = null;
      }
      // Do not clear latestDragRequestKeyRef here: a newer effect run will
      // overwrite it with its own requestKey, and clearing it would create a
      // brief window in which a late-resolving fetch from this run could pass
      // the staleness check against an empty key.
    };
  }, [
    activeFeature,
    activeDragRequest,
    dataViewFeature,
    resolution,
    usePostcodeView,
    viewFeatureIsEnum,
    shareCode,
  ]);

  // Fetch hexagons or postcodes when bounds/filters change
  useEffect(() => {
    if (!bounds) {
      latestDataRequestKeyRef.current = '';
      setLoading(false);
      return;
    }
    latestDataRequestKeyRef.current = dataRequestKey;
    setLoading(true);

    if (debounceRef.current) {
      clearTimeout(debounceRef.current);
    }

    debounceRef.current = setTimeout(async () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
      abortControllerRef.current = new AbortController();

      const requestKey = dataRequestKey;
      try {
        if (usePostcodeView) {
          const params = new URLSearchParams({ bounds: boundsParam });
          if (filtersParam) params.set('filters', filtersParam);
          params.set(
            'fields',
            dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
          );
          if (travelParam) {
            params.set('travel', travelParam);
          }
          if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
          if (shareCode) params.set('share', shareCode);
          const res = await fetch(
            apiUrl('postcodes', params),
            authHeaders({
              signal: abortControllerRef.current.signal,
            })
          );
          if (res.status === 403) {
            const errBody = await res.json();
            if (requestKey !== latestDataRequestKeyRef.current) return;
            if (errBody.error === 'license_required' && errBody.free_zone) {
              setLicenseRequired(true);
              setFreeZone(errBody.free_zone);
              setLoading(false);
              return;
            }
          }
          assertOk(res, 'postcodes');
          if (requestKey !== latestDataRequestKeyRef.current) return;
          setLicenseRequired(false);
          const json: { features: PostcodeFeature[] } = await res.json();
          if (requestKey !== latestDataRequestKeyRef.current) return;
          setPostcodeData(json.features);
          setRawData([]);
          setLoadedDataKey(requestKey);
        } else {
          const params = new URLSearchParams({
            resolution: resolution.toString(),
            bounds: boundsParam,
          });
          if (filtersParam) params.set('filters', filtersParam);
          params.set(
            'fields',
            dataViewFeature && !dataViewFeature.startsWith('tt_') ? dataViewFeature : ''
          );
          if (travelParam) {
            params.set('travel', travelParam);
          }
          if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
          if (shareCode) params.set('share', shareCode);
          const res = await fetch(
            apiUrl('hexagons', params),
            authHeaders({
              signal: abortControllerRef.current.signal,
            })
          );
          if (res.status === 403) {
            const errBody = await res.json();
            if (requestKey !== latestDataRequestKeyRef.current) return;
            if (errBody.error === 'license_required' && errBody.free_zone) {
              setLicenseRequired(true);
              setFreeZone(errBody.free_zone);
              setLoading(false);
              return;
            }
          }
          assertOk(res, 'hexagons');
          if (requestKey !== latestDataRequestKeyRef.current) return;
          setLicenseRequired(false);
          const json: ApiResponse = await res.json();
          if (requestKey !== latestDataRequestKeyRef.current) return;
          setRawData(json.features);
          setPostcodeData([]);
          setLoadedDataKey(requestKey);
        }

        // Clear drag data when committed fetch completes and we're not mid-drag
        if (!activeFeatureRef.current) {
          setDragHexData(null);
          setDragPostcodeData(null);
          setDragDataKey('');
          dragFeatureRef.current = null;
        }
        setLoading(false);
      } catch (err) {
        if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
          logNonAbortError('Failed to fetch data', err);
          setLoading(false);
          // Mark this request settled so a terminal error (e.g. 400 filter_limit or
          // 429 rate_limited) clears the loading spinner instead of hanging it; the
          // last successful data stays visible.
          setLoadedDataKey(requestKey);
        }
      }
    }, DEBOUNCE_MS);

    return () => {
      if (debounceRef.current) {
        clearTimeout(debounceRef.current);
      }
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
        abortControllerRef.current = null;
      }
    };
  }, [
    resolution,
    bounds,
    filters,
    filtersParam,
    boundsParam,
    dataRequestKey,
    dataViewFeature,
    viewFeatureIsEnum,
    usePostcodeView,
    travelParam,
    shareCode,
  ]);

  // Use drag data only when it matches the current view feature and request key.
  // The first render of a new drag must not reuse the previous drag's preview range.
  const dragJustStarted =
    Boolean(activeFeature) &&
    (previousDragStateRef.current.activeFeature !== activeFeature ||
      previousDragStateRef.current.filterStateKey !== filterStateKey);
  const hasMatchingDragData =
    !dragJustStarted &&
    Boolean(activeFeature && viewFeature && activeDragRequest) &&
    dragFeatureRef.current === viewFeature &&
    dragDataKey === activeDragRequest?.requestKey;
  const hasCurrentRangeData = activeFeature
    ? hasMatchingDragData
    : loadedDataKey === dataRequestKey;
  const data = (hasMatchingDragData ? dragHexData : null) ?? rawData;
  const effectivePostcodeData = (hasMatchingDragData ? dragPostcodeData : null) ?? postcodeData;

  // Compute p5/p95 from the data currently being drawn. During slider drags
  // this uses the drag-preview data so the colour scale resets to that preview.
  const dataRange = useMemo((): [number, number] | null => {
    if (!hasCurrentRangeData) return null;
    if (!dataViewFeature) return null;

    const isTravelTime = dataViewFeature.startsWith('tt_');

    if (!isTravelTime) {
      const meta = features.find((f) => f.name === dataViewFeature);
      if (!meta || meta.type === 'enum') return null;
    }

    const vals: number[] = [];

    if (usePostcodeView) {
      if (effectivePostcodeData.length === 0) return null;
      for (const feat of effectivePostcodeData) {
        if (bounds) {
          const [lng, lat] = feat.properties.centroid as [number, number];
          if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
            continue;
        }
        const val = getFiniteNumber(feat.properties[`avg_${dataViewFeature}`]);
        if (val == null) continue;
        const visibleValue = valueInVisibleRange(
          val,
          getFiniteNumber(feat.properties[`min_${dataViewFeature}`]),
          getFiniteNumber(feat.properties[`max_${dataViewFeature}`]),
          filterRange
        );
        if (visibleValue != null) vals.push(visibleValue);
      }
    } else {
      if (data.length === 0) return null;
      for (const item of data) {
        if (bounds) {
          const { lat, lon } = item;
          if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
            continue;
        }
        const val = getFiniteNumber(item[`avg_${dataViewFeature}`]);
        if (val == null) continue;
        const visibleValue = valueInVisibleRange(
          val,
          getFiniteNumber(item[`min_${dataViewFeature}`]),
          getFiniteNumber(item[`max_${dataViewFeature}`]),
          filterRange
        );
        if (visibleValue != null) vals.push(visibleValue);
      }
    }

    if (vals.length === 0) return null;
    // Typed-array sort uses the engine's optimized numeric sort with no
    // per-element comparator call — measurably faster than `vals.sort((a,b)=>a-b)`
    // for the 5k–10k samples a busy viewport produces.
    const sorted = Float64Array.from(vals);
    sorted.sort();
    return [
      percentile(sorted, COLOR_RANGE_LOW_PERCENTILE),
      percentile(sorted, COLOR_RANGE_HIGH_PERCENTILE),
    ];
  }, [
    bounds,
    data,
    dataViewFeature,
    effectivePostcodeData,
    features,
    filterRange,
    hasCurrentRangeData,
    usePostcodeView,
  ]);

  // Live color range for the legend and hex coloring.
  const liveColorRange = useMemo((): [number, number] | null => {
    if (!dataViewFeature) return null;

    // Travel time keys: use dataRange directly (no FeatureMeta)
    if (dataViewFeature.startsWith('tt_')) {
      return dataRange;
    }

    const meta = features.find((f) => f.name === dataViewFeature);
    if (!meta) return null;
    if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
      return [0, meta.values.length - 1];
    }
    if (dataRange) return dataRange;
    if (activeFeature) return null;
    if (loadedDataKey !== dataRequestKey) return null;
    if (meta.min != null && meta.max != null) return [meta.min, meta.max];
    return null;
  }, [activeFeature, dataRequestKey, dataRange, dataViewFeature, features, loadedDataKey]);

  const isEyePreviewingPinnedFeature =
    !activeFeature && dataViewFeature != null && dataViewFeature === pinnedDataViewFeature;

  const [frozenPreviewRange, setFrozenPreviewRange] = useState<{
    feature: string;
    range: [number, number];
  } | null>(null);

  useEffect(() => {
    setFrozenPreviewRange((prev) => {
      if (!pinnedDataViewFeature) {
        resetPreviewScaleAfterSliderRef.current = false;
        return prev ? null : prev;
      }
      return prev?.feature === pinnedDataViewFeature ? prev : null;
    });
  }, [pinnedDataViewFeature]);

  useEffect(() => {
    if (!isEyePreviewingPinnedFeature || !pinnedDataViewFeature) return;

    const meta = pinnedDataViewFeature.startsWith('tt_')
      ? null
      : features.find((f) => f.name === pinnedDataViewFeature);
    const rangeToFreeze =
      dataRange && loadedDataKey === dataRequestKey
        ? dataRange
        : meta?.type === 'enum' && liveColorRange
          ? liveColorRange
          : null;
    if (!rangeToFreeze) return;

    const resetAfterSlider = resetPreviewScaleAfterSliderRef.current;
    setFrozenPreviewRange((prev) =>
      resetAfterSlider || prev?.feature !== pinnedDataViewFeature
        ? { feature: pinnedDataViewFeature, range: rangeToFreeze }
        : prev
    );
    if (resetAfterSlider) resetPreviewScaleAfterSliderRef.current = false;
  }, [
    dataRange,
    dataRequestKey,
    features,
    isEyePreviewingPinnedFeature,
    loadedDataKey,
    liveColorRange,
    pinnedDataViewFeature,
  ]);

  const colorRange = useMemo((): [number, number] | null => {
    if (
      isEyePreviewingPinnedFeature &&
      frozenPreviewRange &&
      frozenPreviewRange.feature === dataViewFeature
    ) {
      return frozenPreviewRange.range;
    }
    return liveColorRange;
  }, [dataViewFeature, frozenPreviewRange, isEyePreviewingPinnedFeature, liveColorRange]);

  const canResetPreviewScale = useMemo(() => {
    if (
      !isEyePreviewingPinnedFeature ||
      !pinnedDataViewFeature ||
      !liveColorRange ||
      loadedDataKey !== dataRequestKey
    ) {
      return false;
    }

    if (pinnedDataViewFeature.startsWith('tt_')) return true;
    return features.find((f) => f.name === pinnedDataViewFeature)?.type !== 'enum';
  }, [
    dataRequestKey,
    features,
    isEyePreviewingPinnedFeature,
    liveColorRange,
    loadedDataKey,
    pinnedDataViewFeature,
  ]);

  const handleResetPreviewScale = useCallback(() => {
    if (!canResetPreviewScale || !pinnedDataViewFeature || !liveColorRange) return;
    setFrozenPreviewRange({ feature: pinnedDataViewFeature, range: liveColorRange });
  }, [canResetPreviewScale, liveColorRange, pinnedDataViewFeature]);

  const handleViewChange = useCallback(
    ({
      resolution: newRes,
      bounds: newBounds,
      visibleBounds: newVisibleBounds,
      zoom: newZoom,
      latitude,
      longitude,
      visibleLatitude,
      visibleLongitude,
    }: ViewChangeParams) => {
      const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
      if (boundsKey !== prevBoundsRef.current) {
        prevBoundsRef.current = boundsKey;
        setResolution(newRes);
        setBounds(newBounds);
      }
      setVisibleBounds(newVisibleBounds);
      setZoom(newZoom);
      setCurrentView({ latitude, longitude, zoom: newZoom });
      setCurrentVisibleView({
        latitude: visibleLatitude ?? latitude,
        longitude: visibleLongitude ?? longitude,
        zoom: newZoom,
      });
    },
    []
  );

  const setInitialView = useCallback(
    (view: { latitude: number; longitude: number; zoom: number }) => {
      setCurrentView(view);
      setCurrentVisibleView(view);
      setZoom(view.zoom);
    },
    []
  );

  // Treat the map as loading whenever the rendered hexagons don't match the
  // current request — covers the brief window between a slider release and
  // the main fetch effect actually firing setLoading(true).
  const isLoading =
    loading || (bounds != null && !licenseRequired && loadedDataKey !== dataRequestKey);

  return {
    data,
    committedHexagonData: rawData,
    postcodeData: effectivePostcodeData,
    resolution,
    bounds,
    visibleBounds,
    loading: isLoading,
    zoom,
    currentView,
    currentVisibleView,
    usePostcodeView,
    colorRange,
    canResetPreviewScale,
    handleResetPreviewScale,
    handleViewChange,
    setInitialView,
    licenseRequired,
    freeZone,
  };
}
