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

import type { ActualListing, POI, 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, countEffectiveFilters } 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,
  type TransportMode,
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import {
  DEMO_MAX_FILTERS,
  REGISTERED_MAX_FILTERS,
  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 { DEFAULT_LISTINGS_MODE, filterListingsByMode, type ListingsMode } from '../../lib/listings';
import { groupFeaturesByCategory } from '../../lib/features';
import {
  getActiveAmenityFilterFeatureNames,
  isPoiFilterFeatureName,
} from '../../lib/poi-distance-filter';
import {
  AreaPane,
  Filters,
  ListingPane,
  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';

const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
const EMPTY_POIS: POI[] = [];

export default function MapPage({
  features,
  poiCategoryGroups,
  initialFilters,
  initialViewState,
  initialPOICategories,
  initialOverlays,
  initialCrimeTypes,
  initialBasemap = 'standard',
  initialColorOpacity = DEFAULT_COLOR_OPACITY,
  initialListingsMode = DEFAULT_LISTINGS_MODE,
  initialTab,
  initialLoading,
  theme,
  pendingInfoFeature,
  onClearPendingInfoFeature,
  onNavigateTo,
  onExportStateChange,
  onDashboardParamsChange,
  onDashboardReadyChange,
  screenshotMode,
  ogMode,
  isMobile = false,
  initialTravelTime,
  initialPostcode,
  shareCode,
  onSessionRejected,
  user,
  onLoginClick,
  onRegisterClick,
  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, overlay and listings panes are mutually exclusive, so a single state
  // tracks which one (if any) is open.
  const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | 'listings' | null>(null);
  const poiPaneOpen = openMapPane === 'poi';
  const overlayPaneOpen = openMapPane === 'overlay';
  const listingsPaneOpen = openMapPane === 'listings';
  const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
  const [listingsMode, setListingsMode] = useState<ListingsMode>(initialListingsMode);
  const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
    initialPostcode ?? null
  );

  // Filter allowance scales with the account tier: anonymous → DEMO_MAX_FILTERS,
  // registered free → REGISTERED_MAX_FILTERS, licensed/admin → unlimited. Hitting the
  // cap opens the upgrade modal.
  const isLoggedIn = !!user;
  // Screenshot/OG renders are non-interactive previews of a (possibly shared) search.
  // They go through the server's internal-request exemption, so they must render the
  // full filter set rather than the capped demo view. Otherwise a 4–5-filter shared
  // search is silently trimmed to the anonymous cap (3) in the generated preview image.
  const filtersUnlimited =
    screenshotMode === true ||
    ogMode === true ||
    user?.subscription === 'licensed' ||
    user?.isAdmin === true;
  const effectiveFilterCap = isLoggedIn ? REGISTERED_MAX_FILTERS : DEMO_MAX_FILTERS;
  const filterLimit = filtersUnlimited ? null : effectiveFilterCap;
  // On a shared link, anonymous visitors may view and adjust the shared filters but
  // not add new ones. Keyed off login state (not paid status) so the "create a free
  // account to build your own" upsell isn't a dead-end: once registered, the user can
  // add filters up to their normal cap right there on the shared search. The
  // `!filtersUnlimited` term also excludes the non-interactive screenshot/OG render
  // (treated as unlimited above), which must show the shared filters in full.
  const lockFilterAdds = !isLoggedIn && !filtersUnlimited && !!shareCode;
  // A shared/bookmarked link can encode more filters than the viewer's tier allows.
  // The initial set is then trimmed to the cap (the server 400s the full request), so
  // the viewer would silently see a broader result than was shared. Count the effective
  // filters (folded + known to the backend, matching what the server counts) so stale
  // or unknown keys don't raise a false alarm; recomputed once features finish loading.
  const initialEffectiveFilterCount = useMemo(
    () =>
      countEffectiveFilters(initialFilters, features) + (initialTravelTime?.entries?.length ?? 0),
    [initialFilters, features, initialTravelTime]
  );
  // Gate on features being loaded: until then countEffectiveFilters can't drop unknown
  // keys and would over-count (firing the popup for a set that actually fits the cap).
  const sharedSearchTrimmed =
    features.length > 0 && !filtersUnlimited && initialEffectiveFilterCount > effectiveFilterCap;
  // Which upsell the modal frames: a shared-link context (lock, or a trimmed shared
  // search), or a plain filter-cap hit.
  const upgradeReason: 'filters' | 'shared' =
    lockFilterAdds || sharedSearchTrimmed ? 'shared' : 'filters';
  const [filterLimitHit, setFilterLimitHit] = useState(false);
  const handleFilterLimitReached = useCallback(() => {
    trackEvent('Upgrade Modal Shown');
    setFilterLimitHit(true);
  }, []);

  // Pop the upgrade modal once when the shared/bookmarked search carried more filters
  // than the viewer's tier allows, so trimmed filters are surfaced, not silently
  // dropped. Fires when the signal first turns true (which may be after features finish
  // loading), and only once so dismissing it sticks.
  const overCapPopupShownRef = useRef(false);
  useEffect(() => {
    if (sharedSearchTrimmed && !overCapPopupShownRef.current) {
      overCapPopupShownRef.current = true;
      handleFilterLimitReached();
    }
  }, [sharedSearchTrimmed, handleFilterLimitReached]);

  // Travel-time destinations count toward the same cap as feature filters (each one
  // restricts which properties match). Cap an over-budget initial travel set (from a
  // bookmarked/shared URL), preferring feature filters, so the first data request
  // never exceeds the server cap.
  const cappedInitialTravelTime = useMemo(() => {
    const initialEntries = initialTravelTime?.entries ?? [];
    if (filtersUnlimited || initialEntries.length === 0) return initialTravelTime;
    const travelBudget = Math.max(0, effectiveFilterCap - Object.keys(initialFilters).length);
    return initialEntries.length > travelBudget
      ? { ...initialTravelTime, entries: initialEntries.slice(0, travelBudget) }
      : initialTravelTime;
  }, [filtersUnlimited, effectiveFilterCap, initialFilters, initialTravelTime]);

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

  const {
    filters,
    activeFeature,
    dragValue,
    pinnedFeature,
    enabledFeatures,
    viewFeature,
    viewSource,
    filterRange,
    handleAddFilter,
    handleFilterChange,
    handleRemoveFilter,
    handleSetFilters,
    handleDragStart,
    handleDragChange,
    handleDragEnd,
    handleDragEndNoCommit,
    handleTogglePin,
    handleCancelPin,
  } = useFilters({
    initialFilters,
    features,
    filterLimit,
    // Travel-time entries reserve slots from the same cap (see cappedInitialTravelTime).
    reservedFilterSlots: filtersUnlimited ? 0 : entries.length,
    lockAdds: lockFilterAdds,
    onFilterLimitReached: handleFilterLimitReached,
  });

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

  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,
    onSessionRejected,
  });

  // 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) => {
      // On a shared link, non-paying users may adjust the shared filters but not build
      // a new set. The AI box is a "build me a search" tool (and it also drives
      // travel-time entries, which bypass the per-add lock), so block it outright here
      // (mirroring handleAddTravelTimeEntry) and surface the upsell instead.
      if (lockFilterAdds) {
        handleFilterLimitReached();
        return;
      }
      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;

      // Non-paying users share one cap across feature filters and travel-time
      // filters, so clamp the AI's combined result (features first, then travel).
      const cappedFilters = filtersUnlimited
        ? result.filters
        : Object.fromEntries(Object.entries(result.filters).slice(0, effectiveFilterCap));
      handleSetFilters(cappedFilters);
      // 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 representableAll = result.travelTimeFilters
        .map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
        .filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
      const travelBudget = filtersUnlimited
        ? representableAll.length
        : Math.max(0, effectiveFilterCap - Object.keys(cappedFilters).length);
      const representable = representableAll.slice(0, travelBudget);

      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,
      effectiveFilterCap,
      fetchAiFilters,
      filters,
      filtersUnlimited,
      lockFilterAdds,
      handleFilterLimitReached,
      getMobileMapFlyToOptions,
      handleSetEntries,
      handleSetFilters,
    ]
  );

  // Travel-time entries share the non-paying user's filter cap, so block adding one
  // when the combined feature + travel count is already at the limit, and block it
  // outright on a shared link, where non-paying users can't add filters at all.
  const handleAddTravelTimeEntry = useCallback(
    (mode: TransportMode) => {
      if (
        !filtersUnlimited &&
        (lockFilterAdds || Object.keys(filters).length + entries.length >= effectiveFilterCap)
      ) {
        handleFilterLimitReached();
        return;
      }
      handleAddEntry(mode);
    },
    [
      filtersUnlimited,
      lockFilterAdds,
      effectiveFilterCap,
      filters,
      entries,
      handleAddEntry,
      handleFilterLimitReached,
    ]
  );

  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,
    loadCrimeRecords,
    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 fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
  // Disabling POIs (clearing every category) must remove all POI cards/markers
  // immediately, not on the next fetch tick. Gate on the selection itself so a
  // stale fetch result can never keep cards on screen.
  const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS;
  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 && listingsMode !== 'none';
  const { listings: actualListings, loading: actualListingsLoading } = useActualListings(
    actualListingsEnabled ? mapData.visibleBounds : null,
    {
      filterParam: actualListingsFilterParam,
      travelParam: actualListingsTravelParam,
      shareCode,
    }
  );
  // The fetch returns every listing in view; the new/non-new split is decided
  // client-side from each listing's Rightmove channel URL.
  const visibleActualListings = useMemo(
    () =>
      actualListingsEnabled
        ? filterListingsByMode(actualListings, listingsMode)
        : EMPTY_ACTUAL_LISTINGS,
    [actualListingsEnabled, actualListings, listingsMode]
  );
  const handleListingsModeChange = useCallback(
    (mode: ListingsMode) => {
      if (!canSeeListings) return;
      setListingsMode(mode);
    },
    [canSeeListings]
  );
  // Losing listings access (e.g. logging out) hides the button that dismisses the
  // pane, so close the pane here to avoid leaving an orphaned card on screen.
  useEffect(() => {
    if (!canSeeListings) {
      setOpenMapPane((pane) => (pane === 'listings' ? null : pane));
    }
  }, [canSeeListings]);
  const selectedPostcodeParam =
    selectedHexagon?.type === 'postcode'
      ? selectedHexagon.id
      : (pendingInitialPostcode ?? undefined);

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

  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,
  });

  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);
  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,
        listingsMode
      ).toString(),
    [
      activeOverlays,
      basemap,
      crimeTypes,
      colorOpacity,
      entries,
      features,
      filters,
      listingsMode,
      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.bounds != null && mapData.currentView != null;

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

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

  // 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. On a shared
  // link adds are blocked regardless of count, so don't auto-clear there; the user
  // dismisses it themselves. Prevents a stale flag from resurfacing spuriously.
  useEffect(() => {
    if (
      filtersUnlimited ||
      (!lockFilterAdds && Object.keys(filters).length + entries.length < effectiveFilterCap)
    ) {
      setFilterLimitHit(false);
    }
  }, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters, entries]);

  // When the cap tightens (logout / session invalidated / license lapse), useFilters
  // re-clamps the feature filters, but travel-time entries, which count toward the same
  // server cap, live here. Trim them to the cap too so the combined request can't exceed
  // it and blank the map. Read via ref so this only fires on a cap change, not on every
  // travel edit (those are already gated by handleAddTravelTimeEntry).
  const entriesRef = useRef(entries);
  entriesRef.current = entries;
  useEffect(() => {
    if (filtersUnlimited) return;
    if (entriesRef.current.length > effectiveFilterCap) {
      handleSetEntries(entriesRef.current.slice(0, effectiveFilterCap));
    }
  }, [effectiveFilterCap, filtersUnlimited, handleSetEntries]);

  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 handleToggleListingsPane = useCallback(() => {
    setOpenMapPane((pane) => (pane === 'listings' ? null : 'listings'));
  }, []);
  const handleCloseListingsPane = useCallback(() => {
    setOpenMapPane((pane) => (pane === 'listings' ? 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}
          onLoadCrimeRecords={loadCrimeRecords}
          scrollTopRef={areaPaneScrollTopRef}
          scrollRestoreKey={
            selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
          }
          scrollSaveDisabled={loadingAreaStats && areaStats == null}
        />
      </Suspense>
    ),
    [
      activeEntries,
      areaStats,
      areaStatsUseFilters,
      features,
      filters,
      hexagonLocation,
      isAreaGroupExpanded,
      loadingAreaStats,
      loadCrimeRecords,
      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 listingsPane = useMemo(
    () => (
      <Suspense fallback={<PaneFallback />}>
        <ListingPane
          mode={listingsMode}
          onModeChange={handleListingsModeChange}
          count={visibleActualListings.length}
          loading={actualListingsLoading}
          onClose={handleCloseListingsPane}
        />
      </Suspense>
    ),
    [
      listingsMode,
      handleListingsModeChange,
      visibleActualListings.length,
      actualListingsLoading,
      handleCloseListingsPane,
    ]
  );

  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={handleAddTravelTimeEntry}
          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,
      handleAddTravelTimeEntry,
      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 is shown when a non-paying user hits the filter cap (or tries
  // to add filters on a shared link). Dismissing it just closes the modal (the
  // filters they have stay applied). Anonymous users are nudged to register for free
  // first (a higher filter allowance + save/share/export); paying is the way to
  // unlimited. Registered users see the lifetime upgrade directly.
  const showFilterUpgradeModal = filterLimitHit && !filtersUnlimited;
  const upgradeModal = showFilterUpgradeModal ? (
    <Suspense fallback={null}>
      <UpgradeModal
        isLoggedIn={isLoggedIn}
        reason={upgradeReason}
        onLoginClick={onLoginClick}
        onRegisterClick={onRegisterClick}
        onRegisterAndUpgrade={() =>
          onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
        }
        onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
        onClose={() => setFilterLimitHit(false)}
      />
    </Suspense>
  ) : null;

  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}
        onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
        listingsPaneOpen={listingsPaneOpen}
        listingsPane={listingsPane}
        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={upgradeModal}
        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}
      onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined}
      listingsPaneOpen={listingsPaneOpen}
      listingsPane={listingsPane}
      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={upgradeModal}
    />
  );
}
