import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';

import type { ActualListing, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings';
import { buildTravelParam } from '../../lib/travel-params';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import {
  MAX_TRAVEL_MINUTES,
  parseServerMode,
  resolveTransitVariant,
  travelFieldKey,
  useTravelTime,
} from '../../hooks/useTravelTime';
import {
  apiUrl,
  authHeaders,
  buildFilterString,
  persistDemoChoice,
  readDemoChoice,
  setDemoCenter,
} from '../../lib/api';
import DemoLocationPrompt from './DemoLocationPrompt';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import {
  DEMO_MAX_FILTERS,
  DEV_LOCATION,
  INITIAL_VIEW_STATE,
  POSTCODE_ZOOM_THRESHOLD,
} from '../../lib/consts';
import { boundsToCenterZoom } from '../../lib/fit-bounds';
import type { OverlayId } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps';
import { useLicense } from '../../hooks/useLicense';
import { stateToParams } from '../../lib/url-state';
import { DEFAULT_COLOR_OPACITY, normalizeColorOpacity } from '../../lib/color-opacity';
import { groupFeaturesByCategory } from '../../lib/features';
import {
  getActiveAmenityFilterFeatureNames,
  isPoiFilterFeatureName,
} from '../../lib/poi-distance-filter';
import {
  AreaPane,
  Filters,
  OverlayPane,
  POIPane,
  PropertiesPane,
  UpgradeModal,
} from './map-page/lazyComponents';
import { PaneFallback } from './map-page/Fallbacks';
import { DesktopMapPage } from './map-page/DesktopMapPage';
import { MobileMapPage } from './map-page/MobileMapPage';
import { ScreenshotMapPage } from './map-page/ScreenshotMapPage';
import { ExportToast } from './map-page/Toasts';
import { MobileMapLegend } from './map-page/MobileMapLegend';
import { useExportController } from './map-page/useExportController';
import {
  useHexagonLocation,
  useJourneyDestination,
  useMapViewFeature,
  useMobileDensityRange,
  useMobileLegendMeta,
} from './map-page/derivedState';
import {
  useHorizontalSwipeNavigationGuard,
  useInitialMapPageView,
  useInitialPostcodeSelection,
  useMobileBackNavigationGuard,
  useScreenshotReadySignal,
} from './map-page/effects';
import { useMobileDrawer } from './map-page/useMobileDrawer';
import type { MapFlyTo, MapPageProps } from './map-page/types';

export type { ExportState } from './map-page/types';

declare const __DEV__: boolean;

const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];

export default function MapPage({
  features,
  poiCategoryGroups,
  initialFilters,
  initialViewState,
  offerDemoLocation,
  initialPOICategories,
  initialOverlays,
  initialCrimeTypes,
  initialBasemap = 'standard',
  initialColorOpacity = DEFAULT_COLOR_OPACITY,
  initialTab,
  initialLoading,
  theme,
  pendingInfoFeature,
  onClearPendingInfoFeature,
  onNavigateTo,
  onExportStateChange,
  onDashboardParamsChange,
  onDashboardReadyChange,
  screenshotMode,
  ogMode,
  isMobile = false,
  initialTravelTime,
  initialPostcode,
  shareCode,
  user,
  onLoginClick,
  onRegisterClick,
  onCheckoutLoginClick,
  onCheckoutRegisterClick,
  deferTutorial = false,
  onSaveSearch,
  savingSearch,
  editingSearch,
  onCancelEdit,
  onUpdateEdit,
  onUpdateEditInPlace,
}: MapPageProps) {
  const { t } = useTranslation();
  const [selectedPOICategories, setSelectedPOICategories] =
    useState<Set<string>>(initialPOICategories);
  const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
    () => new Set(initialOverlays ?? [])
  );
  const [crimeTypes, setCrimeTypes] = useState<Set<string>>(
    () => new Set(initialCrimeTypes ?? CRIME_TYPE_VALUES)
  );
  const [basemap, setBasemap] = useState<BasemapId>(initialBasemap);
  const [colorOpacity, setColorOpacity] = useState(() =>
    normalizeColorOpacity(initialColorOpacity)
  );
  const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
  const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
  // The POI and overlay panes are mutually exclusive, so a single state tracks
  // which one (if any) is open.
  const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | null>(null);
  const poiPaneOpen = openMapPane === 'poi';
  const overlayPaneOpen = openMapPane === 'overlay';
  const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
  const [listingsToggleEnabled, setListingsToggleEnabled] = useState(true);
  const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
    initialPostcode ?? null
  );

  // Demo (unlicensed) users are limited to DEMO_MAX_FILTERS filters; hitting the
  // cap opens the upgrade modal. Licensed users and admins are unlimited.
  const filtersUnlimited = user?.subscription === 'licensed' || user?.isAdmin === true;
  const [filterLimitHit, setFilterLimitHit] = useState(false);
  const handleFilterLimitReached = useCallback(() => setFilterLimitHit(true), []);

  const {
    filters,
    activeFeature,
    dragValue,
    pinnedFeature,
    enabledFeatures,
    viewFeature,
    viewSource,
    filterRange,
    handleAddFilter,
    handleFilterChange,
    handleRemoveFilter,
    handleSetFilters,
    handleDragStart,
    handleDragChange,
    handleDragEnd,
    handleDragEndNoCommit,
    handleTogglePin,
    handleCancelPin,
  } = useFilters({
    initialFilters,
    features,
    filterLimit: filtersUnlimited ? null : DEMO_MAX_FILTERS,
    onFilterLimitReached: handleFilterLimitReached,
  });

  const {
    fetchAiFilters,
    loading: aiFilterLoading,
    error: aiFilterError,
    errorType: aiFilterErrorType,
    notes: aiFilterNotes,
    summary: aiFilterSummary,
  } = useAiFilters();

  const {
    entries,
    activeEntries,
    handleAddEntry,
    handleRemoveEntry,
    handleSetDestination,
    handleSetEntries,
    handleTimeRangeChange,
    handleToggleBest,
    handleToggleNoChange,
    handleToggleOneChange,
    handleToggleNoBuses,
  } = useTravelTime(initialTravelTime);

  const mapFlyToRef = useRef<MapFlyTo | null>(null);
  const areaPaneScrollTopRef = useRef(0);
  const propertiesPaneScrollTopRef = useRef(0);

  const {
    mobileDrawerOpen,
    mobileBottomSheetHeight,
    setMobileBottomSheetHeight,
    openMobileDrawer,
    openMobileDrawerForLocationSearch,
    clearPendingLocationSearchFlyTo,
    queueCurrentLocationFlyTo,
    handleMobileDrawerPanelRectChange,
    handleMobileDrawerClose,
    getMobileMapFlyToOptions,
  } = useMobileDrawer(isMobile, mapFlyToRef);

  const mapData = useMapData({
    filters,
    features,
    viewFeature,
    activeFeature,
    pinnedFeature,
    filterRange,
    travelTimeEntries: entries,
    shareCode,
  });

  // Read the zoom through a ref inside handleAiFilterSubmit so panning/zooming
  // doesn't recreate the callback (it sits in the Filters pane's dependency
  // chain, which would otherwise re-render on every camera move).
  const currentViewZoomRef = useRef<number | undefined>(undefined);
  currentViewZoomRef.current = mapData.currentView?.zoom;

  const handleAiFilterSubmit = useCallback(
    async (query: string) => {
      const context = {
        filters,
        // Send the resolved variant so the AI sees the actual server mode in use,
        // not just the UI-side base mode. Lets the model refine no-change/no-buses.
        travelTime: activeEntries.map((entry) => ({
          mode: resolveTransitVariant(entry),
          label: entry.label,
          min: entry.timeRange?.[0],
          max: entry.timeRange?.[1],
        })),
      };
      const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;

      const result = await fetchAiFilters(query, hasContext ? context : undefined);
      if (!result) return;

      handleSetFilters(result.filters);
      // Filter out variants the UI cannot represent (e.g. transit-one-change*)
      // FIRST so the same filtered list drives both entry state and fly-to.
      // Otherwise we'd fly to a destination for a mode the user can't see.
      const representable = result.travelTimeFilters
        .map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
        .filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);

      handleSetEntries(
        representable.map(({ tt, parsed }) => ({
          mode: parsed.mode,
          noChange: parsed.noChange,
          oneChange: parsed.oneChange,
          noBuses: parsed.noBuses,
          slug: tt.slug,
          label: tt.label,
          timeRange: [tt.min ?? 0, Math.min(tt.max ?? MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES)] as [
            number,
            number,
          ],
          useBest: false,
        }))
      );

      // Move the camera to where the matches actually are — flying to the
      // travel-time anchor often lands on a viewport with zero matches.
      if (result.matchBounds) {
        const target = boundsToCenterZoom(result.matchBounds);
        mapFlyToRef.current?.(target.lat, target.lng, target.zoom, getMobileMapFlyToOptions());
        return;
      }

      const firstTravelTime = representable[0]?.tt;
      if (!firstTravelTime?.slug) return;

      try {
        const res = await fetch(
          apiUrl('travel-destinations', new URLSearchParams({ mode: firstTravelTime.mode })),
          authHeaders({})
        );
        if (!res.ok) return;

        const data: { destinations: { slug: string; lat: number; lon: number }[] } =
          await res.json();
        const destination = data.destinations.find((item) => item.slug === firstTravelTime.slug);
        if (destination) {
          mapFlyToRef.current?.(
            destination.lat,
            destination.lon,
            currentViewZoomRef.current ?? INITIAL_VIEW_STATE.zoom,
            getMobileMapFlyToOptions()
          );
        }
      } catch {
        // Filters are already applied; destination panning is non-critical.
      }
    },
    [
      activeEntries,
      fetchAiFilters,
      filters,
      getMobileMapFlyToOptions,
      handleSetEntries,
      handleSetFilters,
    ]
  );

  const handleClearAll = useCallback(() => {
    handleSetFilters({});
    handleCancelPin();
    handleSetEntries([]);
  }, [handleSetFilters, handleCancelPin, handleSetEntries]);

  const handleTravelTimeRemoveEntry = useCallback(
    (index: number) => {
      const entry = entries[index];
      if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
        handleCancelPin();
      }
      handleRemoveEntry(index);
    },
    [handleCancelPin, handleRemoveEntry, entries, pinnedFeature]
  );

  const handleTravelTimeDragEnd = useCallback(
    (index: number) => {
      const dragEndValue = handleDragEndNoCommit();
      if (dragEndValue) handleTimeRangeChange(index, dragEndValue);
    },
    [handleDragEndNoCommit, handleTimeRangeChange]
  );

  const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries, shareCode);
  const license = useLicense();
  const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
  const activeFilterNames = useMemo(() => new Set(Object.keys(filters)), [filters]);
  const activeAmenityFeatureNames = useMemo(
    () => getActiveAmenityFilterFeatureNames(filters),
    [filters]
  );
  const areaStatsFields = useMemo(
    () =>
      groupFeaturesByCategory(features)
        .filter((group) => isAreaGroupExpanded(group.name))
        .flatMap((group) =>
          group.features
            .filter((feature) => {
              if (group.name !== 'Amenities') return true;
              if (isPoiFilterFeatureName(feature.name)) {
                return activeAmenityFeatureNames.has(feature.name);
              }
              return activeFilterNames.has(feature.name);
            })
            .map((feature) => feature.name)
        ),
    [activeAmenityFeatureNames, activeFilterNames, features, isAreaGroupExpanded]
  );

  const handleTravelTimeSetDestination = useCallback(
    (index: number, slug: string, label: string, _lat: number, _lon: number) => {
      handleSetDestination(index, slug, label);
    },
    [handleSetDestination]
  );

  const journeyDest = useJourneyDestination(entries);

  const {
    selectedHexagon,
    properties,
    propertiesTotal,
    loadingProperties,
    areaStats,
    loadingAreaStats,
    unfilteredAreaCount,
    areaStatsUseFilters,
    setAreaStatsUseFilters,
    hoveredHexagon,
    rightPaneTab,
    setRightPaneTab,
    handleHexagonClick,
    handleHexagonHover,
    handlePropertiesTabClick,
    handleLoadMoreProperties,
    handleCloseSelection,
    selectedPostcodeGeometry,
    handleLocationSearch,
    handleCurrentLocationSearch,
  } = useHexagonSelection({
    filters,
    features,
    hexagonData: mapData.committedHexagonData,
    resolution: mapData.resolution,
    usePostcodeView: mapData.usePostcodeView,
    travelTimeEntries: entries,
    areaStatsFields,
    shareCode,
    journeyDest,
  });

  const handleLocationSearchResult = useCallback(
    (result: SearchedLocation | null) => {
      if (result) {
        const markerLat = result.markerLatitude;
        const markerLng = result.markerLongitude;
        if (markerLat != null && markerLng != null) {
          setCurrentLocation({ lat: markerLat, lng: markerLng });
        } else {
          setCurrentLocation(null);
        }
        handleLocationSearch(
          result.postcode,
          result.geometry,
          result.latitude,
          result.longitude,
          result.openProperties,
          result.focusAddress
        );
        if (isMobile) {
          openMobileDrawerForLocationSearch({
            lat: markerLat ?? result.latitude,
            lng: markerLng ?? result.longitude,
            zoom: result.zoom,
          });
        }
      } else {
        setCurrentLocation(null);
        clearPendingLocationSearchFlyTo();
        handleCloseSelection();
      }
    },
    [
      clearPendingLocationSearchFlyTo,
      handleCloseSelection,
      handleLocationSearch,
      isMobile,
      openMobileDrawerForLocationSearch,
    ]
  );

  const handleCurrentLocationFound = useCallback(
    (lat: number, lng: number) => {
      if (isMobile) {
        queueCurrentLocationFlyTo(lat, lng);
      } else {
        mapFlyToRef.current?.(lat, lng, 17);
      }
      setCurrentLocation({ lat, lng });
      handleCurrentLocationSearch(lat, lng);
      if (isMobile) openMobileDrawer();
    },
    [handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
  );

  const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
  // Hide the upgrade modal as soon as the user dismisses it. We can't rely on
  // the camera fly alone to close it: flying back to the free/shared zone only
  // clears `licenseRequired` once the resulting refetch returns non-403, and
  // when the fly target equals the current view (e.g. "back to shared area"
  // while already at the shared view) `jumpTo` is a no-op, so no refetch fires
  // and the modal would otherwise stay stuck open.
  const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
  const handleZoomToFreeZone = useCallback(() => {
    setUpgradeModalDismissed(true);
    setFilterLimitHit(false);
    // Fly back to the visitor's own free zone. Prefer the server-reported zone
    // (reflects their chosen demo centre — including a GPS choice made this
    // session), then a share-return view, then the initial centre.
    const fz = mapData.freeZone;
    const target =
      shareReturnViewRef.current ??
      (fz
        ? {
            latitude: (fz.south + fz.north) / 2,
            longitude: (fz.west + fz.east) / 2,
            zoom: initialViewState.zoom,
          }
        : initialViewState);
    mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
  }, [initialViewState, mapData.freeZone]);

  // "Check your area" demo prompt: GPS recenters the map AND moves the free zone
  // (the chosen centre is sent to the server via lib/api on each request);
  // declining keeps Canary Wharf. Persisted per session so we don't re-ask.
  const [demoPromptOpen, setDemoPromptOpen] = useState(
    // Also check the persisted choice directly: MapPage remounts on navigation
    // (route key change) while App's `offerDemoLocation` is frozen at mount, so a
    // choice made earlier this session must still suppress a re-prompt.
    () => !!offerDemoLocation && !screenshotMode && !ogMode && readDemoChoice() == null
  );
  const [demoLocating, setDemoLocating] = useState(false);
  const [demoLocationError, setDemoLocationError] = useState<string | null>(null);

  const handleUseMyLocation = useCallback(() => {
    const applyCenter = (center: { lat: number; lon: number }) => {
      setDemoCenter(center);
      persistDemoChoice(center);
      setDemoLocating(false);
      setDemoPromptOpen(false);
      // handleFlyTo uses map.jumpTo (instant) — no flashing 403s through
      // intermediate viewports; the move triggers a refetch carrying demoLat/Lon.
      mapFlyToRef.current?.(
        center.lat,
        center.lon,
        INITIAL_VIEW_STATE.zoom,
        getMobileMapFlyToOptions()
      );
    };
    if (typeof navigator === 'undefined' || !navigator.geolocation) {
      setDemoLocationError(t('locationSearch.geolocationUnsupported'));
      return;
    }
    setDemoLocating(true);
    setDemoLocationError(null);
    navigator.geolocation.getCurrentPosition(
      (position) => applyCenter({ lat: position.coords.latitude, lon: position.coords.longitude }),
      () => {
        // In dev, geolocation is usually blocked (e.g. served over http), so fall
        // back to 10 Downing Street to keep the "data follows you" flow testable.
        if (__DEV__) {
          applyCenter({ lat: DEV_LOCATION.latitude, lon: DEV_LOCATION.longitude });
          return;
        }
        setDemoLocating(false);
        setDemoLocationError(t('locationSearch.geolocationFailed'));
      },
      { enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
    );
  }, [t, getMobileMapFlyToOptions]);

  const handleUseDefaultLocation = useCallback(() => {
    persistDemoChoice('declined');
    setDemoPromptOpen(false);
  }, []);

  // Never show the demo prompt to licensed/admin users — e.g. if the session
  // hydrated as free then refreshed to licensed, or they signed in in another tab.
  useEffect(() => {
    if (filtersUnlimited) setDemoPromptOpen(false);
  }, [filtersUnlimited]);

  const pois = usePOIData(mapData.bounds, selectedPOICategories);
  const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
  const actualListingsFilterParam = useMemo(
    () => buildFilterString(filters, features),
    [filters, features]
  );
  const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
  // Listings are gated behind the per-user `can_see_listings` flag (off by
  // default). Only users with the flag get the toggle button and the fetch;
  // the backing API independently rejects anyone else with 403.
  const canSeeListings = user?.canSeeListings ?? false;
  const actualListingsEnabled = canSeeListings && listingsToggleEnabled;
  const { listings: actualListings, loading: actualListingsLoading } = useActualListings(
    actualListingsEnabled ? mapData.visibleBounds : null,
    {
      filterParam: actualListingsFilterParam,
      travelParam: actualListingsTravelParam,
      shareCode,
    }
  );
  const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS;
  const handleToggleActualListings = useCallback(() => {
    if (!canSeeListings) return;
    setListingsToggleEnabled((enabled) => !enabled);
  }, [canSeeListings]);
  const selectedPostcodeParam =
    selectedHexagon?.type === 'postcode'
      ? selectedHexagon.id
      : (pendingInitialPostcode ?? undefined);

  useUrlSync(
    mapData.currentView,
    filters,
    features,
    selectedPOICategories,
    rightPaneTab,
    entries,
    shareCode,
    activeOverlays,
    basemap,
    crimeTypes,
    selectedPostcodeParam,
    colorOpacity
  );

  useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
  useInitialPostcodeSelection({
    initialPostcode,
    isMobile,
    flyTo: mapFlyToRef,
    onLocationSearch: handleLocationSearch,
    onOpenMobileDrawer: openMobileDrawerForLocationSearch,
    onSettled: () => setPendingInitialPostcode(null),
  });
  useHorizontalSwipeNavigationGuard();
  useMobileBackNavigationGuard(isMobile);
  useScreenshotReadySignal({
    screenshotMode,
    loading: mapData.loading,
    boundsReady: mapData.bounds != null,
    dataLength: mapData.data.length,
    postcodeDataLength: mapData.postcodeData.length,
    usePostcodeView: mapData.usePostcodeView,
    licenseRequired: mapData.licenseRequired,
  });

  const handleMobileHexagonClick = useCallback(
    (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
      handleHexagonClick(id, isPostcode, geometry);
      if (id) {
        openMobileDrawer();
      }
    },
    [handleHexagonClick, openMobileDrawer]
  );

  const hexagonLocation = useHexagonLocation(
    selectedHexagon,
    mapData.postcodeData,
    mapData.resolution,
    areaStats
  );
  const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
  const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
  const densityLabel = t('mapLegend.historicalMatches');
  const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
  const mapViewFeature = useMapViewFeature(viewFeature);
  const mobileDensityRange = useMobileDensityRange(mapData);
  const { exportNotice, clearExportNotice } = useExportController({
    bounds: mapData.bounds,
    filters,
    features,
    travelTimeEntries: entries,
    selectedOverlays: activeOverlays,
    shareCode,
    t,
    onExportStateChange,
  });

  const shareAndSaveView = isMobile
    ? (mapData.currentVisibleView ?? mapData.currentView)
    : mapData.currentView;
  const dashboardParams = useMemo(
    () =>
      stateToParams(
        shareAndSaveView,
        filters,
        features,
        selectedPOICategories,
        rightPaneTab,
        entries,
        shareCode,
        activeOverlays,
        basemap,
        crimeTypes,
        selectedPostcodeParam,
        colorOpacity
      ).toString(),
    [
      activeOverlays,
      basemap,
      crimeTypes,
      colorOpacity,
      entries,
      features,
      filters,
      rightPaneTab,
      selectedPOICategories,
      selectedPostcodeParam,
      shareCode,
      shareAndSaveView,
    ]
  );
  // dashboardParams changes on every camera move; read it through a ref so the
  // save/update handlers (and the Filters pane depending on them) stay stable
  // while panning. The ref always holds the params of the latest render.
  const dashboardParamsRef = useRef(dashboardParams);
  dashboardParamsRef.current = dashboardParams;
  const handleSaveSearch = useCallback(
    async (name: string) => {
      await onSaveSearch?.(name, dashboardParamsRef.current);
    },
    [onSaveSearch]
  );
  const handleUpdateEditInPlaceWithParams = useCallback(async () => {
    await onUpdateEditInPlace?.(dashboardParamsRef.current);
  }, [onUpdateEditInPlace]);
  const checkoutReturnPath = useMemo(
    () => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
    [dashboardParams]
  );

  useEffect(() => {
    onDashboardParamsChange?.(dashboardParams);
  }, [dashboardParams, onDashboardParamsChange]);

  const dashboardReady =
    !initialLoading &&
    !mapData.loading &&
    !mapData.licenseRequired &&
    mapData.bounds != null &&
    mapData.currentView != null;

  useEffect(() => {
    onDashboardReadyChange?.(dashboardReady);
  }, [dashboardReady, onDashboardReadyChange]);

  useEffect(() => {
    return () => {
      onDashboardReadyChange?.(false);
    };
  }, [onDashboardReadyChange]);

  useEffect(() => {
    if (mapData.licenseRequired) {
      trackEvent('Upgrade Modal Shown');
    } else {
      // Back in a viewable area — re-arm so the modal shows again the next time
      // the user pans into a gated area.
      setUpgradeModalDismissed(false);
    }
  }, [mapData.licenseRequired]);

  // Clear the filter-cap upsell once it's no longer relevant: the user became
  // licensed/admin, or dropped back under the cap by removing filters. Prevents a
  // stale flag from resurfacing the modal spuriously.
  useEffect(() => {
    if (filtersUnlimited || Object.keys(filters).length < DEMO_MAX_FILTERS) {
      setFilterLimitHit(false);
    }
  }, [filtersUnlimited, filters]);

  const handleUpgradeClick = useCallback(() => {
    onNavigateTo('pricing');
  }, [onNavigateTo]);
  const handleTogglePoiPane = useCallback(() => {
    setOpenMapPane((pane) => (pane === 'poi' ? null : 'poi'));
  }, []);
  const handleToggleOverlayPane = useCallback(() => {
    setOpenMapPane((pane) => (pane === 'overlay' ? null : 'overlay'));
  }, []);
  const handleClosePoiPane = useCallback(() => {
    setOpenMapPane((pane) => (pane === 'poi' ? null : pane));
  }, []);
  const handleCloseOverlayPane = useCallback(() => {
    setOpenMapPane((pane) => (pane === 'overlay' ? null : pane));
  }, []);
  const handleAreaTabClick = useCallback(() => {
    setRightPaneTab('area');
  }, [setRightPaneTab]);
  const handleMobileDrawerTabChange = useCallback(
    (tab: 'area' | 'properties') => {
      if (tab === 'properties') {
        handlePropertiesTabClick();
      } else {
        setRightPaneTab(tab);
      }
    },
    [handlePropertiesTabClick, setRightPaneTab]
  );

  const renderAreaPane = useCallback(
    () => (
      <Suspense fallback={<PaneFallback />}>
        <AreaPane
          stats={areaStats}
          globalFeatures={features}
          loading={loadingAreaStats}
          hexagonId={selectedHexagon?.id || null}
          isPostcode={selectedHexagon?.type === 'postcode'}
          hexagonLocation={hexagonLocation}
          filters={filters}
          unfilteredCount={unfilteredAreaCount}
          statsUseFilters={areaStatsUseFilters}
          onStatsUseFiltersChange={setAreaStatsUseFilters}
          travelTimeEntries={activeEntries}
          shareCode={shareCode}
          isGroupExpanded={isAreaGroupExpanded}
          onToggleGroup={toggleAreaGroup}
          scrollTopRef={areaPaneScrollTopRef}
          scrollRestoreKey={
            selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
          }
          scrollSaveDisabled={loadingAreaStats && areaStats == null}
        />
      </Suspense>
    ),
    [
      activeEntries,
      areaStats,
      areaStatsUseFilters,
      features,
      filters,
      hexagonLocation,
      isAreaGroupExpanded,
      loadingAreaStats,
      selectedHexagon,
      setAreaStatsUseFilters,
      shareCode,
      toggleAreaGroup,
      unfilteredAreaCount,
    ]
  );

  const renderPropertiesPane = useCallback(
    () => (
      <Suspense fallback={<PaneFallback />}>
        <PropertiesPane
          properties={properties}
          total={propertiesTotal}
          loading={loadingProperties}
          hexagonId={selectedHexagon?.id || null}
          onLoadMore={handleLoadMoreProperties}
          scrollTopRef={propertiesPaneScrollTopRef}
          scrollRestoreKey={
            selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
          }
          scrollSaveDisabled={loadingProperties && properties.length === 0}
        />
      </Suspense>
    ),
    [handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon]
  );

  const poiPane = useMemo(
    () => (
      <Suspense fallback={<PaneFallback />}>
        <POIPane
          groups={poiCategoryGroups}
          selectedCategories={selectedPOICategories}
          onCategoriesChange={setSelectedPOICategories}
          poiCount={pois.length}
          onClose={handleClosePoiPane}
        />
      </Suspense>
    ),
    [handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories]
  );

  const overlayPane = useMemo(
    () => (
      <Suspense fallback={<PaneFallback />}>
        <OverlayPane
          selectedOverlays={activeOverlays}
          onOverlaysChange={setActiveOverlays}
          selectedCrimeTypes={crimeTypes}
          onCrimeTypesChange={setCrimeTypes}
          basemap={basemap}
          onBasemapChange={setBasemap}
          colorOpacity={colorOpacity}
          onColorOpacityChange={setColorOpacity}
          zoomedIn={overlaysZoomedIn}
          onClose={handleCloseOverlayPane}
        />
      </Suspense>
    ),
    [activeOverlays, basemap, colorOpacity, crimeTypes, handleCloseOverlayPane, overlaysZoomedIn]
  );

  const filtersPane = useMemo(
    () => (
      <Suspense fallback={<PaneFallback />}>
        <Filters
          features={features}
          filters={filters}
          activeFeature={activeFeature}
          dragValue={dragValue}
          enabledFeatures={enabledFeatures}
          onAddFilter={handleAddFilter}
          onRemoveFilter={handleRemoveFilter}
          onFilterChange={handleFilterChange}
          onDragStart={handleDragStart}
          onDragChange={handleDragChange}
          onDragEnd={handleDragEnd}
          pinnedFeature={pinnedFeature}
          onTogglePin={handleTogglePin}
          openInfoFeature={pendingInfoFeature}
          onClearOpenInfoFeature={onClearPendingInfoFeature}
          travelTimeEntries={entries}
          onTravelTimeAddEntry={handleAddEntry}
          onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
          onTravelTimeSetDestination={handleTravelTimeSetDestination}
          onTravelTimeRangeChange={handleTimeRangeChange}
          onTravelTimeDragEnd={handleTravelTimeDragEnd}
          onTravelTimeToggleBest={handleToggleBest}
          onTravelTimeToggleNoChange={handleToggleNoChange}
          onTravelTimeToggleOneChange={handleToggleOneChange}
          onTravelTimeToggleNoBuses={handleToggleNoBuses}
          aiFilterLoading={aiFilterLoading}
          aiFilterError={aiFilterError}
          aiFilterErrorType={aiFilterErrorType}
          aiFilterNotes={aiFilterNotes}
          aiFilterSummary={aiFilterSummary}
          onAiFilterSubmit={handleAiFilterSubmit}
          isLoggedIn={!!user}
          onLoginRequired={onRegisterClick}
          isLicensed={user?.subscription === 'licensed'}
          onUpgradeClick={handleUpgradeClick}
          onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
          filterImpacts={filterCounts.impacts}
          onClearAll={handleClearAll}
          onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
          savingSearch={savingSearch}
          editingSearchName={editingSearch?.name ?? null}
          onUpdateSearch={
            editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
          }
          onExitEditing={onCancelEdit}
          destinationDropdownPortal={isMobile ? false : undefined}
        />
      </Suspense>
    ),
    [
      activeFeature,
      aiFilterError,
      aiFilterErrorType,
      aiFilterLoading,
      aiFilterNotes,
      aiFilterSummary,
      dragValue,
      editingSearch,
      enabledFeatures,
      entries,
      features,
      filterCounts.impacts,
      filters,
      handleAddEntry,
      handleAddFilter,
      handleAiFilterSubmit,
      handleClearAll,
      handleDragChange,
      handleDragEnd,
      handleDragStart,
      handleFilterChange,
      handleRemoveFilter,
      handleSaveSearch,
      handleTimeRangeChange,
      handleToggleBest,
      handleToggleNoBuses,
      handleToggleNoChange,
      handleToggleOneChange,
      handleTogglePin,
      handleTravelTimeDragEnd,
      handleTravelTimeRemoveEntry,
      handleTravelTimeSetDestination,
      handleUpdateEditInPlaceWithParams,
      handleUpgradeClick,
      isMobile,
      onCancelEdit,
      onClearPendingInfoFeature,
      onRegisterClick,
      onSaveSearch,
      onUpdateEditInPlace,
      pendingInfoFeature,
      pinnedFeature,
      savingSearch,
      tutorial.resetTutorial,
      user,
    ]
  );

  const mobileLegend = useMemo(
    () => (
      <MobileMapLegend
        mapViewFeature={mapViewFeature}
        colorRange={mapData.colorRange}
        viewSource={viewSource}
        mobileLegendMeta={mobileLegendMeta}
        densityLabel={densityLabel}
        densityRange={mobileDensityRange}
        theme={theme}
        canResetPreviewScale={mapData.canResetPreviewScale}
        onCancelPin={handleCancelPin}
        onResetPreviewScale={mapData.handleResetPreviewScale}
      />
    ),
    [
      densityLabel,
      handleCancelPin,
      mapData.canResetPreviewScale,
      mapData.colorRange,
      mapData.handleResetPreviewScale,
      mapViewFeature,
      mobileDensityRange,
      mobileLegendMeta,
      theme,
      viewSource,
    ]
  );

  const toasts = useMemo(
    () => (
      <ExportToast
        notice={exportNotice}
        closeLabel={t('common.close')}
        onClose={clearExportNotice}
      />
    ),
    [clearExportNotice, exportNotice, t]
  );

  if (screenshotMode) {
    return (
      <ScreenshotMapPage
        mapData={mapData}
        mapViewFeature={mapViewFeature}
        filterRange={filterRange}
        viewSource={viewSource}
        features={features}
        initialViewState={initialViewState}
        theme={theme}
        ogMode={ogMode}
        travelTimeEntries={entries}
        activeOverlays={activeOverlays}
        activeCrimeTypes={crimeTypes}
        basemap={basemap}
        colorOpacity={colorOpacity}
      />
    );
  }

  const editingBar =
    editingSearch && isMobile ? (
      <div className="flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-warm-50 dark:bg-navy-900">
        <span
          className="flex-1 min-w-0 truncate text-xs text-warm-700 dark:text-warm-200"
          title={editingSearch.name}
        >
          <Trans
            i18nKey="savedPage.isBeingUpdated"
            values={{ name: editingSearch.name }}
            components={{
              strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
            }}
          />
        </span>
        <button
          onClick={onCancelEdit}
          className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-100 dark:hover:bg-navy-800"
        >
          {t('common.cancel')}
        </button>
        <button
          onClick={() => onUpdateEdit?.(dashboardParams)}
          disabled={savingSearch || !dashboardReady}
          className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
        >
          {savingSearch ? t('savedPage.updating') : t('common.update')}
        </button>
      </div>
    ) : null;

  // The upgrade modal serves two cases: panning outside the free zone
  // (licenseRequired) and hitting the demo filter cap (filterLimitHit). The zone
  // case takes precedence; for the filter case, dismissing just closes the modal
  // (no camera move).
  const showZoneUpgradeModal = mapData.licenseRequired && !upgradeModalDismissed;
  const showFilterUpgradeModal = !showZoneUpgradeModal && filterLimitHit && !filtersUnlimited;
  const upgradeModal =
    showZoneUpgradeModal || showFilterUpgradeModal ? (
      <Suspense fallback={null}>
        <UpgradeModal
          isLoggedIn={!!user}
          onLoginClick={() =>
            onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
          }
          onRegisterClick={() =>
            onCheckoutRegisterClick
              ? onCheckoutRegisterClick(checkoutReturnPath)
              : onRegisterClick()
          }
          onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
          onZoomToFreeZone={
            showFilterUpgradeModal ? () => setFilterLimitHit(false) : handleZoomToFreeZone
          }
          isShareReturn={showZoneUpgradeModal && !!shareReturnViewRef.current}
        />
      </Suspense>
    ) : null;

  const demoLocationPrompt = demoPromptOpen ? (
    <DemoLocationPrompt
      locating={demoLocating}
      error={demoLocationError}
      onUseLocation={handleUseMyLocation}
      onUseDefault={handleUseDefaultLocation}
    />
  ) : null;

  // Both overlays render where the page components place {upgradeModal}.
  const overlayModals = (
    <>
      {upgradeModal}
      {demoLocationPrompt}
    </>
  );

  if (isMobile) {
    return (
      <MobileMapPage
        initialLoading={initialLoading}
        mapData={mapData}
        pois={pois}
        activeOverlays={activeOverlays}
        activeCrimeTypes={crimeTypes}
        basemap={basemap}
        colorOpacity={colorOpacity}
        mapViewFeature={mapViewFeature}
        filterRange={filterRange}
        viewSource={viewSource}
        onCancelPin={handleCancelPin}
        features={features}
        selectedHexagonId={selectedHexagon?.id || null}
        hoveredHexagonId={hoveredHexagon}
        onHexagonClick={handleMobileHexagonClick}
        onHexagonHover={handleHexagonHover}
        initialViewState={initialViewState}
        flyToRef={mapFlyToRef}
        theme={theme}
        filters={filters}
        selectedPostcodeGeometry={selectedPostcodeGeometry}
        onLocationSearched={handleLocationSearchResult}
        onCurrentLocationFound={handleCurrentLocationFound}
        currentLocation={currentLocation}
        actualListings={visibleActualListings}
        actualListingsEnabled={actualListingsEnabled}
        actualListingsLoading={actualListingsLoading}
        onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
        travelTimeEntries={entries}
        bottomScreenInset={mobileBottomSheetHeight}
        onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
        mobileDrawerOpen={mobileDrawerOpen}
        onMobileDrawerClose={handleMobileDrawerClose}
        onMobileDrawerPanelRectChange={handleMobileDrawerPanelRectChange}
        rightPaneTab={rightPaneTab}
        onMobileDrawerTabChange={handleMobileDrawerTabChange}
        poiPaneOpen={poiPaneOpen}
        onTogglePoiPane={handleTogglePoiPane}
        poiButtonLabel={t('poiPane.pointsOfInterest')}
        poiPane={poiPane}
        overlayPaneOpen={overlayPaneOpen}
        onToggleOverlayPane={handleToggleOverlayPane}
        overlayPane={overlayPane}
        filtersPane={filtersPane}
        mobileLegend={mobileLegend}
        renderAreaPane={renderAreaPane}
        renderPropertiesPane={renderPropertiesPane}
        toasts={toasts}
        upgradeModal={overlayModals}
        editingBar={editingBar}
      />
    );
  }

  return (
    <DesktopMapPage
      initialLoading={initialLoading}
      tutorial={tutorial}
      tutorialTheme={tutorialTheme}
      leftPaneWidth={leftPaneWidth}
      leftPaneHandlers={leftPaneHandlers}
      filtersPane={filtersPane}
      mapData={mapData}
      pois={pois}
      activeOverlays={activeOverlays}
      activeCrimeTypes={crimeTypes}
      basemap={basemap}
      colorOpacity={colorOpacity}
      mapViewFeature={mapViewFeature}
      filterRange={filterRange}
      viewSource={viewSource}
      onCancelPin={handleCancelPin}
      features={features}
      selectedHexagonId={selectedHexagon?.id || null}
      hoveredHexagonId={hoveredHexagon}
      onHexagonClick={handleHexagonClick}
      onHexagonHover={handleHexagonHover}
      initialViewState={initialViewState}
      flyToRef={mapFlyToRef}
      theme={theme}
      filters={filters}
      selectedPostcodeGeometry={selectedPostcodeGeometry}
      onLocationSearched={handleLocationSearchResult}
      onCurrentLocationFound={handleCurrentLocationFound}
      currentLocation={currentLocation}
      actualListings={visibleActualListings}
      actualListingsEnabled={actualListingsEnabled}
      actualListingsLoading={actualListingsLoading}
      onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
      travelTimeEntries={entries}
      densityLabel={densityLabel}
      totalCount={filterCounts.total ?? undefined}
      poiPaneOpen={poiPaneOpen}
      onTogglePoiPane={handleTogglePoiPane}
      poiPane={poiPane}
      overlayPaneOpen={overlayPaneOpen}
      onToggleOverlayPane={handleToggleOverlayPane}
      overlayPane={overlayPane}
      showSelectionPane={!!selectedHexagon}
      rightPaneWidth={rightPaneWidth}
      rightPaneHandlers={rightPaneHandlers}
      rightPaneTab={rightPaneTab}
      onAreaTabClick={handleAreaTabClick}
      onPropertiesTabClick={handlePropertiesTabClick}
      onCloseSelection={handleCloseSelection}
      renderAreaPane={renderAreaPane}
      renderPropertiesPane={renderPropertiesPane}
      toasts={toasts}
      upgradeModal={overlayModals}
    />
  );
}
