import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Layer, Map as MapGL, Source, useControl, ScaleControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
  HexagonData,
  PostcodeFeature,
  PostcodeGeometry,
  ViewState,
  ViewChangeParams,
  POI,
  FeatureMeta,
  Bounds,
  MapFlyToOptions,
  ActualListing,
  SchoolMetadata,
} from '../../types';

import {
  zoomToResolution,
  getVisibleBoundsFromViewState,
  getBoundsWithBottomScreenInset,
  getMapStyle,
  getMapDataBeforeId,
  getPoiIconUrl,
  getMapCenterForTargetScreenPoint,
} from '../../lib/map-utils';
import {
  MAP_MIN_ZOOM,
  MAP_BOUNDS,
  POI_GROUP_COLORS,
  POSTCODE_ZOOM_THRESHOLD,
  POI_AUTO_CARD_ZOOM_THRESHOLD,
} from '../../lib/consts';
import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import type { FeatureFilters } from '../../types';
import { useDeckLayers } from '../../hooks/useDeckLayers';
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { ts } from '../../i18n/server';
import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps';

interface MapProps {
  data: HexagonData[];
  postcodeData: PostcodeFeature[];
  usePostcodeView: boolean;
  pois: POI[];
  activeOverlays?: Set<OverlayId>;
  activeCrimeTypes?: Set<string>;
  basemap?: BasemapId;
  colorOpacity?: number;
  actualListings?: ActualListing[];
  onViewChange: (params: ViewChangeParams) => void;
  viewFeature: string | null;
  colorRange: [number, number] | null;
  filterRange: [number, number] | null;
  viewSource: 'drag' | 'eye' | null;
  onCancelPin: () => void;
  onResetPreviewScale?: () => void;
  canResetPreviewScale?: boolean;
  features: FeatureMeta[];
  selectedHexagonId: string | null;
  hoveredHexagonId: string | null;
  onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
  onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
  initialViewState: ViewState;
  flyToRef?: React.MutableRefObject<
    ((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
  >;
  theme?: 'light' | 'dark';
  screenshotMode?: boolean;
  ogMode?: boolean;
  filters?: FeatureFilters;
  selectedPostcodeGeometry?: PostcodeGeometry | null;
  onLocationSearched?: (location: SearchedLocation | null) => void;
  onCurrentLocationFound?: (lat: number, lng: number) => void;
  currentLocation?: { lat: number; lng: number } | null;
  bounds?: Bounds | null;
  hideLegend?: boolean;
  hideLocationSearch?: boolean;
  hideTopCardsWhenNarrow?: boolean;
  travelTimeEntries?: TravelTimeEntry[];
  densityLabel?: string;
  totalCount?: number;
  bottomScreenInset?: number;
}

const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
const EMPTY_OVERLAYS = new Set<OverlayId>();
const ALL_CRIME_TYPES = new Set<string>(CRIME_TYPE_VALUES);

function formatListingPrice(price: number): string {
  return `£${price.toLocaleString()}`;
}

function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
  const parts: string[] = [];
  if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
  if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
  if (listing.property_sub_type) parts.push(listing.property_sub_type);
  else if (listing.property_type) parts.push(listing.property_type);
  return parts.length > 0 ? parts.join(' · ') : null;
}

function ListingPopupSingleContent({ listing, t }: { listing: ActualListing; t: TFunction }) {
  return (
    <a
      href={listing.listing_url}
      target="_blank"
      rel="noopener noreferrer"
      className="block px-3 py-2"
    >
      {listing.asking_price != null && (
        <div className="text-base font-bold text-teal-600 dark:text-teal-400">
          {formatListingPrice(listing.asking_price)}
          {listing.price_qualifier ? (
            <span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
              {listing.price_qualifier}
            </span>
          ) : null}
        </div>
      )}
      {formatListingHeadline(listing, t) && (
        <div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
          {formatListingHeadline(listing, t)}
        </div>
      )}
      {listing.address && (
        <div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
          {listing.address}
        </div>
      )}
      {listing.postcode && (
        <div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
          {listing.postcode}
        </div>
      )}
      {listing.floor_area_sqm != null && (
        <div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
          {Math.round(listing.floor_area_sqm)} sqm
          {listing.asking_price_per_sqm != null
            ? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm`
            : ''}
        </div>
      )}
      {listing.features.length > 0 && (
        <ul className="mt-1.5 text-[11px] text-warm-600 dark:text-warm-300 list-disc pl-4 space-y-0.5">
          {listing.features.slice(0, 3).map((feature, idx) => (
            <li key={idx} className="line-clamp-1">
              {feature}
            </li>
          ))}
        </ul>
      )}
      <div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
        Open listing ↗
      </div>
    </a>
  );
}

function ListingClusterPopupContent({
  count,
  listings,
  t,
}: {
  count: number;
  listings: ActualListing[];
  t: TFunction;
}) {
  const visibleCount = listings.length;
  return (
    <div>
      <div className="border-b border-warm-200 px-3 py-2 dark:border-warm-700">
        <div className="text-base font-bold text-red-600 dark:text-red-400">
          {count.toLocaleString()} listings
        </div>
        <div className="text-[11px] text-warm-500 dark:text-warm-400">
          {visibleCount > 0
            ? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}`
            : 'Grouped near this map position'}
        </div>
      </div>
      {visibleCount > 0 && (
        <div className="max-h-80 overflow-y-auto py-1">
          {listings.map((listing, idx) => {
            const headline = formatListingHeadline(listing, t);
            return (
              <a
                key={`${listing.listing_url}-${idx}`}
                href={listing.listing_url}
                target="_blank"
                rel="noopener noreferrer"
                className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60"
              >
                <div className="flex items-start justify-between gap-3">
                  <div className="min-w-0">
                    <div className="text-sm font-semibold text-teal-700 dark:text-teal-300">
                      {listing.asking_price != null
                        ? formatListingPrice(listing.asking_price)
                        : 'Listing'}
                    </div>
                    {headline && (
                      <div className="mt-0.5 truncate text-xs text-warm-700 dark:text-warm-200">
                        {headline}
                      </div>
                    )}
                    {listing.address && (
                      <div className="mt-0.5 line-clamp-1 text-[11px] text-warm-500 dark:text-warm-400">
                        {listing.address}
                      </div>
                    )}
                  </div>
                  {listing.postcode && (
                    <div className="shrink-0 text-[11px] font-medium text-warm-400 dark:text-warm-500">
                      {listing.postcode}
                    </div>
                  )}
                </div>
              </a>
            );
          })}
        </div>
      )}
    </div>
  );
}

interface PoiPopupCardData {
  name: string;
  category: string;
  icon_category?: string;
  group: string;
  emoji: string;
  school?: SchoolMetadata;
}

interface Dimensions {
  width: number;
  height: number;
}

const DESKTOP_TOP_CARD_WIDTH = 300;
const DESKTOP_TOP_CARD_GAP = 8;
const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24;
const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH =
  DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH =
  DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
const DESKTOP_TOP_CARD_CLASS = 'w-[300px]';
const DESKTOP_LOCATION_SEARCH_INPUT_CLASS =
  'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500';

type MapContainerStyle = CSSProperties & {
  '--map-mobile-bottom-inset'?: string;
};

function resolveInset(
  pixelValue: number | undefined,
  ratioValue: number | undefined,
  size: number
) {
  return Math.max(0, (pixelValue ?? 0) + (ratioValue ?? 0) * size);
}

function clamp(value: number, min: number, max: number) {
  return Math.min(max, Math.max(min, value));
}

function getMapRelativeVisibleAreaCenter(dimensions: Dimensions, options?: MapFlyToOptions) {
  const area = options?.visibleArea;
  const leftInset = resolveInset(area?.left, area?.leftRatio, dimensions.width);
  const rightInset = resolveInset(area?.right, area?.rightRatio, dimensions.width);
  const topInset = resolveInset(area?.top, area?.topRatio, dimensions.height);
  const bottomInset = resolveInset(area?.bottom, area?.bottomRatio, dimensions.height);

  const left = Math.min(dimensions.width, leftInset);
  const right = Math.max(left, dimensions.width - Math.min(dimensions.width, rightInset));
  const top = Math.min(dimensions.height, topInset);
  const bottom = Math.max(top, dimensions.height - Math.min(dimensions.height, bottomInset));

  return {
    x: (left + right) / 2,
    y: (top + bottom) / 2,
  };
}

function getViewportRelativeVisibleAreaCenter(
  dimensions: Dimensions,
  container: HTMLDivElement | null,
  options?: MapFlyToOptions
) {
  const area = options?.visibleViewportArea;
  if (!area || !container) return null;

  const rect = container.getBoundingClientRect();
  const viewportWidth = window.innerWidth;
  const viewportHeight = window.innerHeight;
  const viewportLeft = resolveInset(area.left, area.leftRatio, viewportWidth);
  const viewportRight = viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth);
  const viewportTop = resolveInset(area.top, area.topRatio, viewportHeight);
  const viewportBottom =
    viewportHeight - resolveInset(area.bottom, area.bottomRatio, viewportHeight);

  const left = clamp(viewportLeft - rect.left, 0, dimensions.width);
  const right = clamp(viewportRight - rect.left, left, dimensions.width);
  const top = clamp(viewportTop - rect.top, 0, dimensions.height);
  const bottom = clamp(viewportBottom - rect.top, top, dimensions.height);

  return {
    x: (left + right) / 2,
    y: (top + bottom) / 2,
  };
}

interface DeckWithPrivateDraw {
  _drawLayers?: (
    redrawReason: string,
    renderOptions?: { viewports?: unknown[]; [key: string]: unknown }
  ) => unknown;
  __propertyMapNullViewportPatch?: boolean;
}

function patchNullViewportDraw(overlay: MapboxOverlay) {
  const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck;
  if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') {
    return;
  }

  const drawLayers = deck._drawLayers.bind(deck);
  deck._drawLayers = (redrawReason, renderOptions) => {
    const viewports = renderOptions?.viewports;
    if (viewports) {
      // Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map.
      const nonNullViewports = viewports.filter(Boolean);
      if (nonNullViewports.length === 0) return;
      if (nonNullViewports.length !== viewports.length) {
        return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports });
      }
    }
    return drawLayers(redrawReason, renderOptions);
  };
  deck.__propertyMapNullViewportPatch = true;
}

class SafeMapboxOverlay extends MapboxOverlay {
  onAdd(map: unknown) {
    const element = super.onAdd(map);
    patchNullViewportDraw(this);
    return element;
  }

  setProps(props: Parameters<MapboxOverlay['setProps']>[0]) {
    super.setProps(props);
    patchNullViewportDraw(this);
  }
}

function getPoiGroupColor(group: string): [number, number, number] {
  const color = POI_GROUP_COLORS[group];
  if (!color) {
    throw new Error(`Missing POI group color for '${group}'`);
  }
  return color;
}

/** Best-effort web URL from a free-text website field — GIAS stores some with
 * "http://", some without, and some as bare hostnames. */
function normalizeSchoolWebsiteUrl(raw: string): string | null {
  const trimmed = raw.trim();
  if (!trimmed) return null;
  if (/^https?:\/\//i.test(trimmed)) return trimmed;
  if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`;
  return null;
}

function renderSchoolMetadata(school: SchoolMetadata) {
  // First line collects the headline classification (phase, type, religious
  // character) so the popup is scannable even when most fields are absent.
  const headline: string[] = [];
  if (school.phase) headline.push(school.phase);
  if (school.type) headline.push(school.type);

  const pupilsLine =
    school.pupils !== undefined && school.capacity !== undefined
      ? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils`
      : school.pupils !== undefined
        ? `${school.pupils.toLocaleString()} pupils`
        : school.capacity !== undefined
          ? `Capacity ${school.capacity.toLocaleString()}`
          : null;

  const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;

  return (
    <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-xs text-warm-600 dark:text-warm-300">
      {headline.length > 0 && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Type</dt>
          <dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
        </>
      )}
      {school.age_range && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Ages</dt>
          <dd className="dark:text-warm-200">{school.age_range}</dd>
        </>
      )}
      {school.gender && school.gender !== 'Mixed' && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Gender</dt>
          <dd className="dark:text-warm-200">{school.gender}</dd>
        </>
      )}
      {pupilsLine && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Pupils</dt>
          <dd className="dark:text-warm-200">{pupilsLine}</dd>
        </>
      )}
      {school.fsm_percent !== undefined && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Free meal</dt>
          <dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
        </>
      )}
      {school.ofsted_rating && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Ofsted</dt>
          <dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
        </>
      )}
      {school.sixth_form === 'Has a sixth form' && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
          <dd className="dark:text-warm-200">Yes</dd>
        </>
      )}
      {school.religious_character &&
        school.religious_character !== 'Does not apply' &&
        school.religious_character !== 'None' && (
          <>
            <dt className="text-warm-500 dark:text-warm-400">Religion</dt>
            <dd className="dark:text-warm-200">{school.religious_character}</dd>
          </>
        )}
      {school.admissions_policy && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Admissions</dt>
          <dd className="dark:text-warm-200">{school.admissions_policy}</dd>
        </>
      )}
      {school.trust && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Trust</dt>
          <dd className="dark:text-warm-200">{school.trust}</dd>
        </>
      )}
      {(school.address || school.postcode) && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Address</dt>
          <dd className="dark:text-warm-200">
            {[school.address, school.postcode].filter(Boolean).join(', ')}
          </dd>
        </>
      )}
      {school.local_authority && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">LA</dt>
          <dd className="dark:text-warm-200">{school.local_authority}</dd>
        </>
      )}
      {school.head_name && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Head</dt>
          <dd className="dark:text-warm-200">{school.head_name}</dd>
        </>
      )}
      {websiteUrl && (
        <>
          <dt className="text-warm-500 dark:text-warm-400">Website</dt>
          <dd className="truncate">
            <a
              href={websiteUrl}
              target="_blank"
              rel="noreferrer noopener"
              className="pointer-events-auto text-teal-600 hover:underline dark:text-teal-400"
            >
              {websiteUrl.replace(/^https?:\/\//, '')}
            </a>
          </dd>
        </>
      )}
    </dl>
  );
}

function PoiPopupCardContent({ poi }: { poi: PoiPopupCardData }) {
  return (
    <div className="px-3 py-2 max-w-[280px]">
      <div className="flex items-center gap-2">
        <img
          src={getPoiIconUrl(poi.category, poi.emoji, poi.icon_category, poi.name)}
          alt=""
          aria-hidden="true"
          loading="lazy"
          referrerPolicy="no-referrer"
          className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
        />
        <div className="min-w-0">
          <div className="font-semibold dark:text-warm-100">{poi.name}</div>
          <div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
            <span
              className="inline-block w-2 h-2 rounded-full flex-shrink-0"
              style={{
                backgroundColor: `rgb(${getPoiGroupColor(poi.group).join(',')})`,
              }}
            />
            {ts(poi.category)}
          </div>
        </div>
      </div>
      {poi.school && renderSchoolMetadata(poi.school)}
    </div>
  );
}

function getRenderedViewState(map: MapRef | null): ViewState | null {
  if (!map) return null;

  const center = map.getCenter();
  return {
    longitude: center.lng,
    latitude: center.lat,
    zoom: map.getZoom(),
    pitch: map.getPitch(),
    bearing: map.getBearing(),
  };
}

function getRenderedVisibleCenter(
  map: MapRef | null,
  dimensions: Dimensions,
  bottomScreenInset: number
): Pick<ViewState, 'latitude' | 'longitude'> | null {
  if (!map || dimensions.width <= 0 || dimensions.height <= 0) return null;

  const visibleBottomInset = clamp(bottomScreenInset, 0, dimensions.height);
  const visibleCenterY = (dimensions.height - visibleBottomInset) / 2;
  const center = map.unproject([dimensions.width / 2, visibleCenterY]);

  return {
    longitude: center.lng,
    latitude: center.lat,
  };
}

function DeckOverlay({
  layers,
  getTooltip,
}: {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  layers: any[];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  getTooltip: any;
}) {
  const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true }));

  useEffect(() => {
    overlay.setProps({
      layers: layers.filter(Boolean),
      getTooltip,
    });
  }, [overlay, layers, getTooltip]);

  return null;
}

function overlayTileUrl(path: string): string {
  return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`;
}

function OverlayTileLayers({
  activeOverlays,
  activeCrimeTypes,
  zoom,
}: {
  activeOverlays: Set<OverlayId>;
  activeCrimeTypes: Set<string>;
  zoom: number;
}) {
  if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null;

  const showNoise = activeOverlays.has('noise');
  const showCrime = activeOverlays.has('crime-hotspots');
  const showTrees = activeOverlays.has('trees-outside-woodlands');
  const showPropertyBorders = activeOverlays.has('property-borders');

  // Restrict the heatmap to the selected crime types. When every type is
  // selected we omit the filter entirely so all features contribute.
  const crimeFilter =
    activeCrimeTypes.size >= CRIME_TYPE_VALUES.length
      ? undefined
      : ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]];

  return (
    <>
      {showNoise && (
        <Source
          id="overlay-noise-source"
          type="raster"
          tiles={[overlayTileUrl('noise')]}
          tileSize={256}
          minzoom={OVERLAY_MIN_ZOOM.noise}
          maxzoom={14}
        >
          <Layer
            id="overlay-noise"
            type="raster"
            minzoom={POSTCODE_ZOOM_THRESHOLD}
            paint={{
              'raster-opacity': 0.68,
              'raster-fade-duration': 120,
            }}
          />
        </Source>
      )}

      {showCrime && (
        <Source
          id="overlay-crime-source"
          type="vector"
          tiles={[overlayTileUrl('crime-hotspots')]}
          minzoom={OVERLAY_MIN_ZOOM['crime-hotspots']}
          maxzoom={15}
        >
          <Layer
            id="overlay-crime-heatmap"
            type="heatmap"
            source-layer="crime_hotspots"
            minzoom={POSTCODE_ZOOM_THRESHOLD}
            filter={crimeFilter as never}
            paint={
              {
                'heatmap-weight': [
                  'interpolate',
                  ['linear'],
                  ['coalesce', ['get', 'count'], ['get', 'weight'], 1],
                  0,
                  0,
                  10,
                  1,
                ],
                'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
                'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
                'heatmap-opacity': 0.72,
                'heatmap-color': [
                  'interpolate',
                  ['linear'],
                  ['heatmap-density'],
                  0,
                  'rgba(0, 0, 0, 0)',
                  0.2,
                  'rgb(253, 224, 71)',
                  0.45,
                  'rgb(249, 115, 22)',
                  0.75,
                  'rgb(220, 38, 38)',
                  1,
                  'rgb(127, 29, 29)',
                ],
              } as never
            }
          />
        </Source>
      )}

      {showTrees && (
        <Source
          id="overlay-trees-source"
          type="vector"
          tiles={[overlayTileUrl('trees-outside-woodlands')]}
          minzoom={OVERLAY_MIN_ZOOM['trees-outside-woodlands']}
          maxzoom={16}
        >
          <Layer
            id="overlay-tree-polygons"
            type="fill"
            source-layer="trees_outside_woodlands"
            minzoom={POSTCODE_ZOOM_THRESHOLD}
            paint={
              {
                'fill-color': '#1f9d55',
                'fill-opacity': [
                  'interpolate',
                  ['linear'],
                  ['coalesce', ['get', 'area_sqm'], 0],
                  0,
                  0.28,
                  250,
                  0.62,
                ],
                'fill-outline-color': 'rgba(15, 81, 50, 0.65)',
              } as never
            }
          />
        </Source>
      )}

      {showPropertyBorders && (
        <Source
          id="overlay-property-borders-source"
          type="vector"
          tiles={[overlayTileUrl('property-borders')]}
          minzoom={OVERLAY_MIN_ZOOM['property-borders']}
          maxzoom={16}
        >
          <Layer
            id="overlay-property-borders"
            type="line"
            source-layer="property_borders"
            minzoom={POSTCODE_ZOOM_THRESHOLD}
            paint={
              {
                'line-color': '#b45309',
                'line-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0.35, 18, 0.85],
                'line-width': ['interpolate', ['linear'], ['zoom'], 15, 0.4, 18, 1.4],
              } as never
            }
          />
        </Source>
      )}
    </>
  );
}

export default memo(function Map({
  data,
  postcodeData,
  usePostcodeView,
  pois,
  activeOverlays = EMPTY_OVERLAYS,
  activeCrimeTypes = ALL_CRIME_TYPES,
  basemap = 'standard',
  colorOpacity = 1,
  actualListings = EMPTY_ACTUAL_LISTINGS,
  onViewChange,
  viewFeature,
  colorRange,
  filterRange,
  viewSource,
  onCancelPin,
  onResetPreviewScale,
  canResetPreviewScale = false,
  features,
  selectedHexagonId,
  hoveredHexagonId,
  onHexagonClick,
  onHexagonHover,
  initialViewState,
  flyToRef,
  theme = 'light',
  screenshotMode = false,
  ogMode = false,
  filters = {},
  selectedPostcodeGeometry,
  onLocationSearched,
  onCurrentLocationFound,
  currentLocation,
  bounds: viewportBounds,
  hideLegend = false,
  hideLocationSearch = false,
  hideTopCardsWhenNarrow = false,
  travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
  densityLabel: densityLabelProp,
  totalCount: totalCountProp,
  bottomScreenInset = 0,
}: MapProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<MapRef | null>(null);
  const { t } = useTranslation();
  const modes = useTranslatedModes();
  const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
  const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
  const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
  const [mapReady, setMapReady] = useState(false);

  // In screenshot mode, use the prop directly for instant updates (no async lag)
  const viewState = screenshotMode ? initialViewState : internalViewState;

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    let initialized = false;
    const observer = new ResizeObserver((entries) => {
      const { width, height } = entries[0].contentRect;
      if (width > 0 && height > 0) {
        if (!initialized) {
          initialized = true;
          setDimensions({ width, height });
        } else {
          if (resizeTimer) clearTimeout(resizeTimer);
          resizeTimer = setTimeout(() => setDimensions({ width, height }), 150);
        }
      }
    });

    observer.observe(container);
    return () => {
      observer.disconnect();
      if (resizeTimer) clearTimeout(resizeTimer);
    };
  }, []);

  useEffect(() => {
    if (dimensions.width === 0 || dimensions.height === 0) return;

    let frame = 0;
    const emit = () => {
      const renderedViewState = getRenderedViewState(mapRef.current);
      // mapRef can be null on the very first effect run if MapLibre hasn't
      // finished mounting; retry next frame so the initial bounds always reach
      // the data hook.
      if (!renderedViewState) {
        frame = window.requestAnimationFrame(emit);
        return;
      }
      const bounds = getVisibleBoundsFromViewState(
        renderedViewState,
        dimensions.width,
        dimensions.height,
        bottomScreenInset
      );
      const visibleBounds = bounds;
      const resolution = zoomToResolution(renderedViewState.zoom);
      const renderedVisibleCenter =
        getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ??
        renderedViewState;

      onViewChange({
        resolution,
        bounds,
        visibleBounds,
        zoom: renderedViewState.zoom,
        latitude: renderedViewState.latitude,
        longitude: renderedViewState.longitude,
        visibleLatitude: renderedVisibleCenter.latitude,
        visibleLongitude: renderedVisibleCenter.longitude,
      });
    };
    frame = window.requestAnimationFrame(emit);

    return () => window.cancelAnimationFrame(frame);
  }, [viewState, dimensions, bottomScreenInset, onViewChange]);

  const handleMove = useCallback((evt: { viewState: ViewState }) => {
    setInternalViewState((prev) => {
      const next = evt.viewState;
      // Skip re-render when viewport values haven't changed (e.g. container resize
      // fires move events with identical lat/lng/zoom). Returning the same reference
      // tells React to bail out.
      if (
        prev.latitude === next.latitude &&
        prev.longitude === next.longitude &&
        prev.zoom === next.zoom &&
        prev.pitch === next.pitch &&
        prev.bearing === next.bearing
      ) {
        return prev;
      }
      return next;
    });
  }, []);

  const handleIdle = useCallback(() => {
    if (screenshotMode) window.__map_idle = true;
  }, [screenshotMode]);

  const handleLoad = useCallback(() => {
    setMapReady(true);
  }, []);

  const handleFlyTo = useCallback(
    (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
      setInternalViewState((prev) => {
        const targetPoint =
          getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ??
          getMapRelativeVisibleAreaCenter(dimensions, options);
        const center = getMapCenterForTargetScreenPoint(
          lat,
          lng,
          zoom,
          dimensions.width,
          dimensions.height,
          targetPoint.x,
          targetPoint.y
        );

        return { ...prev, ...center, zoom };
      });
    },
    [dimensions]
  );

  if (flyToRef) flyToRef.current = handleFlyTo;

  const mapStyle = useMemo(() => getMapStyle(theme, basemap), [theme, basemap]);
  const mapDataBeforeId = useMemo(() => getMapDataBeforeId(basemap), [basemap]);
  const maxBounds = useMemo(
    () => getBoundsWithBottomScreenInset(MAP_BOUNDS, MAP_MIN_ZOOM, bottomScreenInset),
    [bottomScreenInset]
  );
  const mapContainerStyle = useMemo<MapContainerStyle>(
    () => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}),
    [bottomScreenInset]
  );
  const hideDesktopTopCardsForWidth =
    hideTopCardsWhenNarrow &&
    dimensions.width > 0 &&
    dimensions.width < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH;
  const stackDesktopTopCards =
    hideTopCardsWhenNarrow &&
    dimensions.width >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH &&
    dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH;
  const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth;
  const showLegend = !hideLegend && !hideDesktopTopCardsForWidth;
  const desktopTopCardsLayoutClass = stackDesktopTopCards
    ? 'flex-col items-start'
    : 'items-start justify-between';

  const {
    layers,
    popupInfo,
    clearPopupInfo,
    visiblePois,
    listingPopup,
    clearListingPopup,
    hoverPosition,
    countRange,
    postcodeCountRange,
    colorFeatureMeta,
    handleMouseLeave,
  } = useDeckLayers({
    data,
    postcodeData,
    usePostcodeView,
    zoom: viewState.zoom,
    pois,
    actualListings,
    viewFeature,
    colorRange,
    filterRange,
    features,
    selectedHexagonId,
    hoveredHexagonId,
    onHexagonClick,
    onHexagonHover,
    theme,
    selectedPostcodeGeometry,
    currentLocation,
    bounds: viewportBounds,
    travelTimeEntries,
    mapDataBeforeId,
    colorOpacity,
  });

  const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD;
  const autoPoiCards = useMemo(() => {
    const map = mapRef.current;
    if (!showAutoPoiCards || !mapReady || !map || dimensions.width <= 0 || dimensions.height <= 0) {
      return [];
    }

    return visiblePois.flatMap((poi) => {
      const point = map.project([poi.lng, poi.lat]);
      if (
        !Number.isFinite(point.x) ||
        !Number.isFinite(point.y) ||
        point.x < 0 ||
        point.x > dimensions.width ||
        point.y < 0 ||
        point.y > dimensions.height
      ) {
        return [];
      }
      return [{ poi, x: point.x, y: point.y }];
    });
    // viewState isn't read directly but drives map.project — recompute when the camera moves.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);

  return (
    <div
      className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
      ref={containerRef}
      style={mapContainerStyle}
      onMouseLeave={handleMouseLeave}
    >
      <MapGL
        ref={mapRef}
        {...viewState}
        onMove={handleMove}
        onLoad={handleLoad}
        onIdle={handleIdle}
        mapStyle={mapStyle}
        style={{ width: '100%', height: '100%' }}
        attributionControl={false}
        dragRotate={false}
        touchZoomRotate={true}
        touchPitch={false}
        keyboard={true}
        pitchWithRotate={false}
        minZoom={MAP_MIN_ZOOM}
        maxBounds={maxBounds}
      >
        <OverlayTileLayers
          activeOverlays={activeOverlays}
          activeCrimeTypes={activeCrimeTypes}
          zoom={viewState.zoom}
        />
        <DeckOverlay layers={layers} getTooltip={null} />
        {!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
      </MapGL>
      {basemap === 'satellite' && (
        <div
          className="pointer-events-auto absolute left-2 z-10 max-w-[calc(100%_-_1rem)] rounded bg-white/85 px-1.5 py-0.5 text-[10px] leading-tight text-warm-600 shadow-sm dark:bg-warm-900/85 dark:text-warm-300"
          style={{ bottom: bottomScreenInset > 0 ? bottomScreenInset + 8 : 34 }}
        >
          Sentinel-2 cloudless by EOX, contains modified Copernicus Sentinel data 2024
        </div>
      )}
      {screenshotMode ? (
        ogMode ? (
          <div className="absolute inset-0 z-20 pointer-events-none flex flex-col">
            {/* Center: Logo card with hero text */}
            <div className="flex-1 flex items-center justify-center">
              <div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10 max-w-[1040px]">
                <LogoIcon className="w-24 h-24 shrink-0 text-teal-400" />
                <span
                  className="font-bold text-white/50"
                  style={{ fontSize: '4rem', lineHeight: 1.05, maxWidth: '760px' }}
                >
                  {t('map.ogTitle')}
                </span>
              </div>
            </div>

            {/* Bottom bar */}
            <div className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-10 py-4 bg-white">
              <div className="flex items-center gap-6">
                <span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
                  {t('map.ogPropertyPrices')}
                </span>
                <span className="text-warm-300">|</span>
                <span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
                  {t('map.ogEnergyRatings')}
                </span>
                <span className="text-warm-300">|</span>
                <span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
                  {t('map.ogSchools')}
                </span>
                <span className="text-warm-300">|</span>
                <span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
                  {t('map.ogCrimeStats')}
                </span>
                <span className="text-warm-300">|</span>
                <span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
                  {t('map.ogTransport')}
                </span>
              </div>
              <span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
                perfect-postcode.co.uk
              </span>
            </div>
          </div>
        ) : null
      ) : (
        <>
          {(showLocationSearch || showLegend) && (
            <div
              className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${desktopTopCardsLayoutClass}`}
            >
              {showLocationSearch && (
                <LocationSearch
                  onFlyTo={handleFlyTo}
                  onLocationSearched={onLocationSearched}
                  onCurrentLocationFound={onCurrentLocationFound}
                  onMouseEnter={handleMouseLeave}
                  className={DESKTOP_TOP_CARD_CLASS}
                  inputClassName={DESKTOP_LOCATION_SEARCH_INPUT_CLASS}
                />
              )}
              {showLegend &&
                (viewFeature && colorRange ? (
                  viewFeature.startsWith('tt_') ? (
                    <MapLegend
                      featureLabel={t('travel.travelTime', {
                        mode: modes.label(
                          viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
                        ),
                      })}
                      range={colorRange}
                      showCancel={viewSource === 'eye'}
                      onCancel={onCancelPin}
                      onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
                      resetScaleDisabled={!canResetPreviewScale}
                      mode="feature"
                      theme={theme}
                      suffix=" min"
                      className={DESKTOP_TOP_CARD_CLASS}
                    />
                  ) : colorFeatureMeta ? (
                    <MapLegend
                      featureLabel={
                        viewSource === 'eye'
                          ? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
                          : ts(colorFeatureMeta.name)
                      }
                      range={colorRange}
                      showCancel={viewSource === 'eye'}
                      onCancel={onCancelPin}
                      onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
                      resetScaleDisabled={!canResetPreviewScale}
                      mode="feature"
                      enumValues={
                        colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
                      }
                      featureName={colorFeatureMeta.name}
                      theme={theme}
                      suffix={colorFeatureMeta.suffix}
                      raw={colorFeatureMeta.raw}
                      className={DESKTOP_TOP_CARD_CLASS}
                    />
                  ) : null
                ) : (
                  <MapLegend
                    featureLabel={densityLabel}
                    range={
                      usePostcodeView
                        ? [postcodeCountRange.min, postcodeCountRange.max]
                        : [countRange.min, countRange.max]
                    }
                    totalCount={
                      totalCountProp ??
                      (usePostcodeView ? postcodeCountRange.total : countRange.total)
                    }
                    showCancel={false}
                    onCancel={onCancelPin}
                    mode="density"
                    theme={theme}
                    className={DESKTOP_TOP_CARD_CLASS}
                  />
                ))}
            </div>
          )}
          {autoPoiCards.map(({ poi, x, y }) => (
            <div
              key={poi.id}
              className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
              style={{
                left: x,
                top: y - 12,
                transform: 'translate(-50%, -100%)',
                zIndex: 9,
              }}
            >
              <PoiPopupCardContent poi={poi} />
            </div>
          ))}
          {popupInfo && (!showAutoPoiCards || popupInfo.isCluster) && (
            <div
              className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
              style={{
                left: popupInfo.x,
                top: popupInfo.y - 50,
                transform: 'translateX(-50%)',
                zIndex: 30,
              }}
            >
              <button
                className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
                onClick={clearPopupInfo}
              >
                <CloseIcon className="w-3 h-3" />
              </button>
              {popupInfo.isCluster ? (
                <div className="px-3 py-2 text-center">
                  <div className="text-lg font-bold text-teal-600 dark:text-teal-400">
                    {popupInfo.clusterCount}
                  </div>
                  <div className="text-warm-500 dark:text-warm-400 text-xs">
                    {t('common.places')}
                  </div>
                </div>
              ) : (
                <PoiPopupCardContent poi={popupInfo} />
              )}
            </div>
          )}
          {listingPopup && (
            <div
              className={`pointer-events-auto absolute rounded-lg bg-white text-sm shadow-lg dark:bg-warm-800 dark:text-white ${
                listingPopup.mode === 'cluster' ? 'w-80 max-w-[calc(100vw-2rem)]' : 'max-w-[280px]'
              }`}
              style={{
                left: listingPopup.x,
                top: listingPopup.y - 12,
                transform: 'translate(-50%, -100%)',
                zIndex: 30,
              }}
              onMouseLeave={clearListingPopup}
            >
              <button
                type="button"
                className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
                onClick={clearListingPopup}
              >
                <CloseIcon className="w-3 h-3" />
              </button>
              {listingPopup.mode === 'single' ? (
                <ListingPopupSingleContent listing={listingPopup.listing} t={t} />
              ) : (
                <ListingClusterPopupContent
                  count={listingPopup.count}
                  listings={listingPopup.listings}
                  t={t}
                />
              )}
            </div>
          )}
          {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
            <HoverCard
              x={hoverPosition.x}
              y={hoverPosition.y}
              id={hoveredHexagonId}
              isPostcode={usePostcodeView}
              data={
                usePostcodeView
                  ? postcodeData.find((f) => f.properties.postcode === hoveredHexagonId)
                      ?.properties || null
                  : data.find((d) => d.h3 === hoveredHexagonId) || null
              }
              filters={filters}
              features={features}
            />
          )}
        </>
      )}
    </div>
  );
});
