/**
 * S7. Guidance (turn-by-turn) — this file owns the prop contract
 * (`GuidanceScreenProps`, below); src/screens/index.ts just re-exports it.
 * Integration pass (see FINDINGS.md) closed most of the original contract
 * gaps: `styleJson`, `route`, `units` and `onShowAbout` now reach this
 * screen as props, threaded from App.tsx. When `route` is supplied, the
 * full route line (ahead) plus true consumed-route dimming (behind the
 * puck) render against its real geometry, and Overview fits its true
 * bounds; when it's absent (e.g. a resumed session with no S6 hand-off)
 * this screen falls back to the breadcrumb trail built from snapped
 * locations, as before. The pan-break signal (`followOverridden`) remains
 * screen-local — no callback for it exists on the state machine yet.
 */
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  Dimensions,
  ActivityIndicator,
  Image,
  type NativeSyntheticEvent,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import {
  Camera,
  type CameraRef,
  GeoJSONSource,
  Layer,
  LayerAnnotation,
  Map as MapLibreMap,
  type MapRef,
  Marker,
  type ViewStateChangeEvent,
} from "@maplibre/maplibre-react-native";
import {
  ChaseLayer,
  type ChaseTarget,
  type PuckStyle,
  PUCK_IMAGE_SOURCES,
  puckPxForZoom,
} from "./ChaseLayer";
import { RouteArrows } from "./RouteArrows";
import { GuidanceSettingsSheet } from "./GuidanceSettingsSheet";
import Svg, { Circle, Path } from "react-native-svg";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { Button, Card, Chip, ManeuverIcon } from "../../components";
import { minTouchTarget, radius, spacing, theme, typeScale } from "../../theme";
import type { GuidanceEvent, Route, SelectedPlace, Units } from "../../state/types";
import type {
  LaneDirection,
  LaneInfo,
  ManeuverType,
} from "../../../modules/mapmap-react-native/src/MapmapNav.types";
import {
  DestinationPin,
  FALLBACK_MAP_STYLE,
  MapAttribution,
  MapWatermark,
  boundsOf,
  formatClockTime,
  formatDistance,
  formatDistanceToTurn,
  formatDuration,
  lineStringFromPoints,
  toLngLat,
} from "../mapChrome";

export type GuidanceScreenProps = {
  place: SelectedPlace;
  guidance?: GuidanceEvent;
  isRerouting: boolean;
  isFollowingCamera: boolean;
  isOverview: boolean;
  voiceMuted: boolean;
  onToggleVoiceMuted: () => void;
  /** User pan broke follow — chip to restore chase camera. */
  onRecentre: () => void;
  onToggleOverview: () => void;
  /** Instant exit, no confirm, per spec. */
  onExit: () => void;
  /** Dev builds only; hidden behind a triple-tap on the ETA in release. */
  simulateDriveEnabled?: boolean;
  onSimulateDrive?: () => void;
  /** Resolved territory style JSON (App.tsx fetches once per active
   * territory); null before it resolves or if the fetch failed — the map
   * falls back to a background-only placeholder either way. */
  styleJson?: string | null;
  /** The active route from the last computeRoute() selection on S6 — draws
   * the full route line + true consumed-route dimming when present; falls
   * back to a breadcrumb trail when absent. */
  route?: Route;
  units: Units;
  /** Attribution tap → About/Licences (S11); falls back to the OSM
   * copyright URL when absent. */
  onShowAbout?: () => void;
  /** Posted speed limit for the current road, km/h. The bridge contract
   * (MapmapNav.types.ts) has no speed-limit field yet — this stays
   * undefined (roundel hidden) until the native SDK provides one; never
   * invented locally. */
  speedLimitKph?: number;
  /** Drive source: "live" GPS (default) or "simulate" (demo). */
  navMode?: "live" | "simulate";
  /** Set the drive source (live GPS vs simulated demo). */
  onNavModeChange?: (mode: "live" | "simulate") => void;
};

/** Static paint/layout objects — module scope so identity never churns at
 * ~1 Hz (line-cap/line-join are layout properties, not paint). Shared by
 * both the route-geometry consumed segment (when `route` is supplied) and
 * the breadcrumb-trail fallback (when it isn't) — same "already driven,
 * dimmed" treatment either way. */
const CONSUMED_TRAIL_LAYOUT = {
  "line-cap": "round" as const,
  "line-join": "round" as const,
};
const CONSUMED_TRAIL_PAINT = {
  "line-color": theme.textSecondary,
  "line-width": 5,
  "line-opacity": 0.5,
};
/** The active route line — white casing under a signal-blue line, drawn along
 * the WHOLE route so it never vanishes behind the puck. Width scales with zoom:
 * thicker when zoomed out (so the route reads on the overview), thinner when
 * zoomed in close. */
const ROUTE_CASING_WIDTH = [
  "interpolate",
  ["linear"],
  ["zoom"],
  10,
  13,
  14,
  15,
  18,
  17,
  20,
  18,
] as unknown as number;
const ROUTE_LINE_WIDTH = [
  "interpolate",
  ["linear"],
  ["zoom"],
  10,
  9,
  14,
  11,
  18,
  13,
  20,
  14,
] as unknown as number;
const ROUTE_CASING_PAINT = {
  "line-color": theme.surface,
  "line-width": ROUTE_CASING_WIDTH,
};
const ROUTE_LINE_PAINT = {
  "line-color": theme.signal,
  "line-width": ROUTE_LINE_WIDTH,
};
const TRIPLE_TAP_WINDOW_MS = 700;
const MAX_TRAIL_POINTS = 400;

/** Auto chase-zoom: a continuous ramp (no discrete steps, which read as
 * sudden zoom "jumps"), tightest right at the manoeuvre and easing out on
 * long straights. Closer overall than a typical overview so the driver sees
 * the immediate road, per feedback. */
function zoomForDistance(distanceMeters: number): number {
  const NEAR_ZOOM = 19.2; // at the turn
  const FAR_ZOOM = 18.0; // long straight — closer than before, per feedback
  const FAR_DISTANCE = 1000; // metres at which we reach FAR_ZOOM
  const t = Math.max(0, Math.min(1, distanceMeters / FAR_DISTANCE));
  return NEAR_ZOOM - t * (NEAR_ZOOM - FAR_ZOOM);
}

/** Keep any user pinch-zoom offset (and the resulting absolute zoom) within
 * sane bounds so a stray region reading can't run the camera away. */
function clampZoom(zoom: number): number {
  return Math.max(14, Math.min(20, zoom));
}
const MAX_USER_ZOOM_OFFSET = 3;
/** Camera-bearing low-pass factor per fix (0..1): lower = smoother/laggier.
 * With denser fixes we can track corners a bit tighter without whip. */
const BEARING_SMOOTH_ALPHA = 0.35;

const KPH_PER_MPS = 3.6;
const MPH_PER_MPS = 2.23694;
const MPH_PER_KPH = 0.621371;

/** speedMps → rounded display value in the caller's units; mph when
 * `units === "mi"`, km/h otherwise. */
function speedForDisplay(speedMps: number, units: Units): number {
  const value = units === "mi" ? speedMps * MPH_PER_MPS : speedMps * KPH_PER_MPS;
  return Math.max(0, Math.round(value));
}

// Standard posted road-sign values ACTUALLY used, so we never draw a sign that
// doesn't exist (e.g. "48"). UK limits are signed in mph; most of the world in
// km/h. UK `maxspeed` is tagged in mph and reaches us as rounded km/h
// conversions, so an imperial limit converts back to a real mph sign.
const UK_SIGN_MPH = new Set([20, 30, 40, 50, 60, 70]);
const METRIC_SIGN_KMH = new Set([30, 50, 60, 70, 80, 90, 100, 110, 120, 130]);

/** Map a posted limit (km/h, from the routing graph) to the number on the
 * actual road sign, or null when it doesn't correspond to a standard sign — in
 * which case we show nothing rather than invent one. Prefers an mph sign when
 * the value converts cleanly (UK-first; ambiguous values like 80 km/h resolve
 * to mph). Ideally the platform would tag each edge's sign unit — see
 * PLATFORM-IMPROVEMENTS.md. */
function speedLimitSign(limitKph: number): number | null {
  const kmh = Math.round(limitKph);
  const mph = Math.round(limitKph * MPH_PER_KPH);
  if (UK_SIGN_MPH.has(mph) && Math.abs(mph / MPH_PER_KPH - kmh) <= 1.5) return mph;
  if (METRIC_SIGN_KMH.has(kmh)) return kmh;
  return null;
}

function SpeakerIcon({ muted, color }: { muted: boolean; color: string }): React.JSX.Element {
  return (
    <Svg width={20} height={20} viewBox="0 0 24 24" accessibilityLabel={muted ? "muted" : "unmuted"}>
      <Path d="M4 9v6h4l5 5V4L8 9H4Z" fill={color} />
      {muted ? (
        <Path
          d="M16.2 8.6l5.4 6.8M21.6 8.6l-5.4 6.8"
          stroke={color}
          strokeWidth={2}
          strokeLinecap="round"
        />
      ) : (
        <Path
          d="M16.5 8.8c1.3 1 1.3 5.4 0 6.4M18.8 6.5c2.6 2.3 2.6 8.7 0 11"
          stroke={color}
          strokeWidth={1.6}
          strokeLinecap="round"
          fill="none"
        />
      )}
    </Svg>
  );
}

/** LaneDirection → ManeuverType so each lane arrow reuses ManeuverIcon's
 * hand-drawn glyphs instead of a second icon set. */
const LANE_DIRECTION_TO_MANEUVER_TYPE: Record<LaneDirection, ManeuverType> = {
  left: "turn-left",
  "slight-left": "slight-left",
  through: "straight",
  "slight-right": "slight-right",
  right: "turn-right",
  uturn: "uturn",
};

/** Row of lane arrows under the manoeuvre banner — active lane(s) in
 * signal blue at full opacity, everything else muted, matching the banner
 * card's white-surface-plus-shadow language. */
function LaneBar({ lanes }: { lanes: LaneInfo[] }): React.JSX.Element {
  return (
    <View style={styles.laneBarRow} accessibilityLabel="Lane guidance">
      {lanes.map((lane, laneIndex) => (
        <View
          key={laneIndex}
          style={[styles.laneCell, lane.active ? styles.laneCellActive : styles.laneCellInactive]}
        >
          {lane.directions.map((direction, directionIndex) => (
            <ManeuverIcon
              key={directionIndex}
              type={LANE_DIRECTION_TO_MANEUVER_TYPE[direction]}
              size={22}
              color={lane.active ? theme.signal : theme.textSecondary}
            />
          ))}
        </View>
      ))}
    </View>
  );
}

/**
 * Manoeuvre banner + chase-camera map + bottom bar — S7. When `route` is
 * supplied (the active route selected on S6), the ahead/consumed lines and
 * the Overview fit use its real geometry; without it, the "consumed route"
 * falls back to a breadcrumb trail built from the sequence of snapped
 * locations seen so far this session.
 */
export function GuidanceScreen({
  place,
  guidance,
  isRerouting,
  isFollowingCamera,
  isOverview,
  voiceMuted,
  onToggleVoiceMuted,
  onRecentre,
  onToggleOverview,
  onExit,
  simulateDriveEnabled,
  onSimulateDrive,
  styleJson,
  route,
  units,
  onShowAbout,
  speedLimitKph,
  navMode = "live",
  onNavModeChange,
}: GuidanceScreenProps): React.JSX.Element {
  const [settingsOpen, setSettingsOpen] = useState(false);
  // Chase-camera tilt in degrees (0 = flat 2D top-down, ~55 = strong 3D lean),
  // user-set via the pitch slider in Driving options.
  const [pitchDeg, setPitchDeg] = useState(50);
  const [puckStyle, setPuckStyle] = useState<PuckStyle>("chevron");
  // Live map zoom, tracked to size the puck (smaller when zoomed out).
  const [mapZoom, setMapZoom] = useState(18);
  const insets = useSafeAreaInsets();

  const [bannerHeight, setBannerHeight] = useState(120);
  const [bottomBarHeight, setBottomBarHeight] = useState(110);
  const [followOverridden, setFollowOverridden] = useState(false);
  // Zoom the driver pinched to, held as an offset from the auto chase-zoom so
  // their chosen scale rides along with the chase instead of snapping back on
  // the next tick. Reset when they re-centre or enter overview.
  const [userZoomOffset, setUserZoomOffset] = useState(0);
  // True while the driver is actively touching the map. The follow loop drives
  // the camera every frame, so it would fight (and win against) a live pinch or
  // pan — we pause following while a gesture is in flight, then decide on
  // settle whether it was a pinch (keep chasing) or a pan (release).
  const [userGesturing, setUserGesturing] = useState(false);
  const [trail, setTrail] = useState<Array<{ latitude: number; longitude: number }>>([]);
  const tapTimestamps = useRef<number[]>([]);
  const cameraRef = useRef<CameraRef>(null);
  const mapRef = useRef<MapRef>(null);
  const [currentRoad, setCurrentRoad] = useState<string | null>(null);
  // Live guidance snapshot for the region-change handler, which must read the
  // current puck position / distance without re-subscribing every tick.
  const guidanceRef = useRef<GuidanceEvent | undefined>(guidance);
  guidanceRef.current = guidance;

  useEffect(() => {
    if (isOverview) {
      setFollowOverridden(false);
      setUserZoomOffset(0);
    }
  }, [isOverview]);

  // Breadcrumb fallback only — skipped once a real `route` is available,
  // since the consumed segment is then cut straight from its geometry.
  useEffect(() => {
    if (!guidance || route) return;
    const point = { latitude: guidance.location.latitude, longitude: guidance.location.longitude };
    setTrail((prev) => {
      const last = prev[prev.length - 1];
      if (last && last.latitude === point.latitude && last.longitude === point.longitude) {
        return prev;
      }
      const next = [...prev, point];
      return next.length > MAX_TRAIL_POINTS ? next.slice(next.length - MAX_TRAIL_POINTS) : next;
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [guidance?.location.latitude, guidance?.location.longitude, !!route]);

  const trailGeoJSON = useMemo<GeoJSON.Feature<GeoJSON.LineString> | null>(() => {
    if (route || trail.length < 2) return null;
    return lineStringFromPoints(trail);
  }, [route, trail]);

  /** The whole route line — drawn in full at all times so it never disappears
   * behind the puck. (We previously split it into consumed/ahead, which read as
   * the line vanishing once the puck passed a segment.) Null with no `route`. */
  const fullRouteGeoJSON = useMemo<GeoJSON.Feature<GeoJSON.LineString> | null>(() => {
    if (!route || route.geometry.length < 2) return null;
    return lineStringFromPoints(route.geometry);
  }, [route]);

  const windowHeight = Dimensions.get("window").height;
  const windowWidth = Dimensions.get("window").width;
  const chasePadding = useMemo(
    () => ({
      // Large top padding drops the camera centre (= the puck) to roughly
      // two-thirds down the screen, like every turn-by-turn app.
      top: Math.max(insets.top + bannerHeight + spacing[6], windowHeight * 0.6),
      bottom: insets.bottom + bottomBarHeight + spacing[6],
      left: spacing[6],
      right: spacing[6],
    }),
    [insets.top, insets.bottom, bannerHeight, bottomBarHeight, windowHeight]
  );
  const overviewPadding = useMemo(
    () => ({
      top: insets.top + bannerHeight + spacing[6],
      bottom: insets.bottom + bottomBarHeight + spacing[6],
      left: spacing[6],
      right: spacing[6],
    }),
    [insets.top, insets.bottom, bannerHeight, bottomBarHeight]
  );

  const cameraFollowActive = !isOverview && isFollowingCamera && !followOverridden && !!guidance;
  // The single source of truth for "the camera is actively chasing": the fixed
  // overlay puck shows exactly when this is true, and the map-anchored marker
  // puck shows exactly when it's false — so they're never both on screen (the
  // double-puck seen mid-pinch). A live gesture temporarily hands over.
  const followingActive = cameraFollowActive && !userGesturing;

  // Low-pass the camera bearing so it doesn't whip on sharp corners or jitter
  // on noisy headings (Mapbox does the same in its ViewportDataSource).
  const smoothedBearingRef = useRef<number | null>(null);

  // Desired chase state for the follow camera. Zoom ramps with distance-to-turn
  // and carries any pinch offset; bearing is smoothed.
  const chaseTarget = useMemo<ChaseTarget | null>(() => {
    if (!guidance) return null;
    const raw = guidance.location.bearingDegrees;
    const prev = smoothedBearingRef.current;
    let bearing = raw;
    if (prev != null) {
      const delta = ((raw - prev + 540) % 360) - 180;
      bearing = (prev + delta * BEARING_SMOOTH_ALPHA + 360) % 360;
    }
    smoothedBearingRef.current = bearing;
    return {
      longitude: guidance.location.longitude,
      latitude: guidance.location.latitude,
      bearing,
      zoom: clampZoom(zoomForDistance(guidance.distanceToManeuverMeters) + userZoomOffset),
      // Driver-set tilt (0 = flat top-down, ~55 = strong 3D lean).
      pitch: pitchDeg,
    };
  }, [guidance, userZoomOffset, pitchDeg]);

  // Screen rotation for the overlay puck: the map points along the SMOOTHED
  // camera bearing (up), but the road at the puck runs along the RAW heading —
  // rotate the puck by the difference so it follows the road through corners
  // instead of stubbornly pointing up while the camera bearing lags.
  const puckScreenRotation =
    guidance && chaseTarget
      ? ((guidance.location.bearingDegrees - chaseTarget.bearing + 540) % 360) - 180
      : 0;

  // Camera focal point (screen px) — where the puck sits and where we sample
  // the road under it for the current-road banner.
  const focalX =
    chasePadding.left + (windowWidth - chasePadding.left - chasePadding.right) / 2;
  const focalY =
    chasePadding.top + (windowHeight - chasePadding.top - chasePadding.bottom) / 2;

  // Current road name comes from the route's own leg data (Valhalla street
  // names), not from sampling map-tile labels — so it's always the road the
  // route is actually on, updates the instant we leave a roundabout/junction,
  // and never picks up a cross-street. Keep the last known name when a leg is
  // unnamed so the banner doesn't flicker empty on minor roads.
  useEffect(() => {
    const name = guidance?.currentRoadName;
    if (name) setCurrentRoad(name);
  }, [guidance?.currentRoadName]);

  // Latest overview padding, read imperatively so a padding settle doesn't
  // re-fit the overview.
  const overviewPaddingRef = useRef(overviewPadding);
  overviewPaddingRef.current = overviewPadding;

  // The camera is driven imperatively. ChaseLayer owns it while chasing; these
  // two effects own the non-follow states so the two never fight over cameraRef.
  // Overview: frame the whole route once, on entry.
  useEffect(() => {
    const cam = cameraRef.current;
    if (!cam || !isOverview) return;
    const routeBounds = route ? boundsOf(route.geometry) : null;
    if (routeBounds) {
      cam.fitBounds(routeBounds, { padding: overviewPaddingRef.current, duration: 500, easing: "ease" });
    } else {
      const g = guidanceRef.current;
      const center = g ? toLngLat(g.location) : toLngLat(place.position);
      cam.setStop({ center, zoom: 13.5, bearing: 0, pitch: 0, padding: overviewPaddingRef.current, duration: 500, easing: "ease" });
    }
  }, [isOverview, route, place.position]);

  // Pre-start / no-guidance: sit on the destination. (When the user has panned
  // away mid-drive we deliberately do nothing here — the camera stays where
  // they left it until they tap Re-centre.)
  const hasGuidance = !!guidance;
  useEffect(() => {
    const cam = cameraRef.current;
    if (!cam || isOverview || hasGuidance) return;
    cam.setStop({ center: toLngLat(place.position), zoom: 15, bearing: 0, pitch: 0, duration: 400, easing: "ease" });
  }, [isOverview, hasGuidance, place.position]);

  const chipVisible = isOverview || followOverridden || !isFollowingCamera;
  const chipLabel = isOverview ? "Chase view" : "Re-centre";
  const [exitConfirmOpen, setExitConfirmOpen] = useState(false);

  const handleChipPress = useCallback(() => {
    if (isOverview) {
      onToggleOverview();
    } else {
      setFollowOverridden(false);
      setUserZoomOffset(0);
      onRecentre();
    }
  }, [isOverview, onToggleOverview, onRecentre]);

  // Distinguish a pinch-zoom (keep chasing, just remember the driver's scale)
  // from a pan-away (break follow, offer Re-centre). Fired on every settle,
  // including our own programmatic camera moves — `isUserInteraction` gates
  // those out. A pinch keeps the map centred on the puck, so a large drift of
  // the map centre from the current location is the pan signal.
  // Gesture begins → pause the follow loop so the finger owns the camera.
  const handleRegionWillChange = useCallback(
    (event: NativeSyntheticEvent<ViewStateChangeEvent>) => {
      if (event.nativeEvent.userInteraction && !isOverview) setUserGesturing(true);
    },
    [isOverview]
  );

  const handleRegionDidChange = useCallback(
    (event: NativeSyntheticEvent<ViewStateChangeEvent>) => {
      const view = event.nativeEvent;
      // Track the live zoom (any change, ours or the user's) to size the puck —
      // only when it moves meaningfully, to avoid churning re-renders.
      setMapZoom((prev) => (Math.abs(prev - view.zoom) > 0.25 ? view.zoom : prev));
      if (!view.userInteraction || isOverview) return;
      // Gesture settled — the follow loop may resume (as a pinch) unless the
      // pan check below releases it.
      setUserGesturing(false);
      const current = guidanceRef.current;
      if (!current) return;

      const [lng, lat] = view.center;
      const metresPerDegLat = 111_320;
      const dNorth = (lat - current.location.latitude) * metresPerDegLat;
      const dEast =
        (lng - current.location.longitude) *
        metresPerDegLat *
        Math.cos((current.location.latitude * Math.PI) / 180);
      const driftMetres = Math.hypot(dNorth, dEast);

      // Panned away from the puck → hand the camera to the user.
      if (driftMetres > 160) {
        setFollowOverridden(true);
        return;
      }
      // Still on the puck → a pinch/rotate. Preserve their zoom as an offset
      // from the auto chase-zoom so it rides along on subsequent ticks.
      if (cameraFollowActive) {
        const auto = zoomForDistance(current.distanceToManeuverMeters);
        const offset = Math.max(
          -MAX_USER_ZOOM_OFFSET,
          Math.min(MAX_USER_ZOOM_OFFSET, view.zoom - auto)
        );
        setUserZoomOffset(offset);
      }
    },
    [isOverview, cameraFollowActive]
  );

  const handleEtaTap = useCallback(() => {
    if (!onSimulateDrive) return;
    const now = Date.now();
    const recent = tapTimestamps.current.filter((t) => now - t < TRIPLE_TAP_WINDOW_MS);
    recent.push(now);
    tapTimestamps.current = recent;
    if (recent.length >= 3) {
      tapTimestamps.current = [];
      onSimulateDrive();
    }
  }, [onSimulateDrive]);

  const speedPillVisible = guidance?.state === "navigating";
  const displaySpeed = guidance ? speedForDisplay(guidance.location.speedMps, units) : 0;
  const speedUnitLabel = units === "mi" ? "mph" : "km/h";
  // The sign shows the real road-sign number (or nothing if the limit isn't a
  // standard sign) — independent of the speed pill's units.
  const displaySpeedLimit =
    typeof speedLimitKph === "number" ? speedLimitSign(speedLimitKph) : null;
  // Over-limit compared in km/h (independent of the sign's or the pill's unit),
  // with ~3 km/h grace so GPS noise doesn't trip a false red.
  const speedKphNow = guidance ? guidance.location.speedMps * KPH_PER_MPS : 0;
  const overLimit = typeof speedLimitKph === "number" && speedKphNow > speedLimitKph + 3;

  const rerouting = isRerouting || guidance?.state === "rerouting";
  const banner = guidance?.banner;
  const bannerState: "starting" | "rerouting" | "active" = !guidance
    ? "starting"
    : rerouting
      ? "rerouting"
      : "active";
  const lanes = guidance?.state === "navigating" ? banner?.lanes : undefined;
  const laneBarVisible = !!lanes && lanes.length > 0;

  return (
    <View style={styles.fill}>
      <MapLibreMap
        ref={mapRef}
        style={StyleSheet.absoluteFill}
        mapStyle={styleJson ?? FALLBACK_MAP_STYLE}
        logo={false}
        attribution={false}
        compass
        compassPosition={{ top: insets.top + bannerHeight + spacing[3], right: spacing[3] }}
        touchPitch
        onRegionWillChange={handleRegionWillChange}
        onRegionDidChange={handleRegionDidChange}
      >
        <Camera
          ref={cameraRef}
          initialViewState={{ center: toLngLat(place.position), zoom: 15, pitch: 0 }}
        />

        {fullRouteGeoJSON ? (
          <GeoJSONSource id="guidance-route" data={fullRouteGeoJSON}>
            <Layer
              id="guidance-route-casing"
              type="line"
              paint={ROUTE_CASING_PAINT}
              layout={CONSUMED_TRAIL_LAYOUT}
            />
            <Layer
              id="guidance-route-line"
              type="line"
              paint={ROUTE_LINE_PAINT}
              layout={CONSUMED_TRAIL_LAYOUT}
            />
          </GeoJSONSource>
        ) : (
          trailGeoJSON && (
            <GeoJSONSource id="guidance-consumed-trail" data={trailGeoJSON}>
              <Layer
                id="guidance-consumed-trail-line"
                type="line"
                paint={CONSUMED_TRAIL_PAINT}
                layout={CONSUMED_TRAIL_LAYOUT}
              />
            </GeoJSONSource>
          )
        )}

        <RouteArrows route={route} />

        <Marker lngLat={toLngLat(place.position)} anchor="bottom">
          <DestinationPin />
        </Marker>

        <ChaseLayer
          cameraRef={cameraRef}
          target={chaseTarget}
          active={followingActive}
          padding={chasePadding}
          puckStyle={puckStyle}
          zoom={mapZoom}
        />
      </MapLibreMap>

      {/* Follow-mode puck: a FIXED screen overlay at the camera focal point. It
       * never moves, so it cannot stutter/desync against the natively-animated
       * map — the map slides under it. Bearing-up, so it always points up =
       * direction of travel; the map rotates beneath it on corners. */}
      {followingActive && (
        <View
          pointerEvents="none"
          style={[
            styles.followPuck,
            {
              left:
                chasePadding.left +
                (windowWidth - chasePadding.left - chasePadding.right) / 2 -
                puckPxForZoom(mapZoom) / 2,
              top:
                chasePadding.top +
                (windowHeight - chasePadding.top - chasePadding.bottom) / 2 -
                puckPxForZoom(mapZoom) / 2,
              width: puckPxForZoom(mapZoom),
              height: puckPxForZoom(mapZoom),
            },
          ]}
        >
          <Image
            source={PUCK_IMAGE_SOURCES[puckStyle]}
            style={[
              styles.followPuckImage,
              { transform: [{ rotate: `${puckScreenRotation}deg` }] },
            ]}
          />
        </View>
      )}

      <MapWatermark />
      <MapAttribution onPress={onShowAbout} />

      {onNavModeChange && (
        <Pressable
          onPress={() => setSettingsOpen(true)}
          accessibilityRole="button"
          accessibilityLabel="Driving options"
          style={[
            styles.optionsFab,
            { bottom: bottomBarHeight + insets.bottom + spacing[3] },
          ]}
          testID="guidance-options"
        >
          <GearIcon />
          {navMode === "simulate" && <View style={styles.optionsDot} />}
        </Pressable>
      )}

      {chipVisible && (
        <View style={[styles.recentreWrap, { bottom: bottomBarHeight + insets.bottom + spacing[3] }]}>
          <Chip label={chipLabel} tone="signal" onPress={handleChipPress} />
        </View>
      )}

      {speedPillVisible && (
        <View
          style={[styles.speedWrap, { bottom: bottomBarHeight + insets.bottom + spacing[3] }]}
          testID="speed-pill"
        >
          {/* Speed-limit sign sits ABOVE the current speed. */}
          {typeof displaySpeedLimit === "number" && (
            <View
              style={styles.speedLimitRoundel}
              testID="speed-limit-roundel"
              accessibilityLabel={`Speed limit ${displaySpeedLimit}`}
            >
              <Text style={styles.speedLimitValue}>{displaySpeedLimit}</Text>
            </View>
          )}
          <View style={[styles.speedPill, overLimit && styles.speedPillOver]}>
            <Text style={[styles.speedValue, overLimit && styles.speedTextOver]}>{displaySpeed}</Text>
            <Text style={[styles.speedUnit, overLimit && styles.speedTextOver]}>{speedUnitLabel}</Text>
          </View>
        </View>
      )}

      {/* Current road name, centred between the speed pill and the gear —
       * sampled from the map under the puck (see the query effect). */}
      {followingActive && currentRoad && (
        <View
          style={[styles.roadWrap, { bottom: bottomBarHeight + insets.bottom + spacing[3] }]}
          pointerEvents="none"
          testID="current-road"
        >
          <View style={styles.roadPill}>
            <Text style={styles.roadText} numberOfLines={1}>
              {currentRoad}
            </Text>
          </View>
        </View>
      )}

      <View
        style={[styles.bannerCard, { top: insets.top + spacing[3] }]}
        onLayout={(e) => setBannerHeight(e.nativeEvent.layout.height)}
      >
        <Card>
        <View style={styles.bannerRow}>
          {bannerState === "active" ? (
            <>
              <View style={styles.bannerIconWrap}>
                <ManeuverIcon
                  type={banner?.maneuverType ?? "straight"}
                  size={44}
                  roundaboutExit={banner?.roundaboutExit}
                  color={theme.textPrimary}
                />
              </View>
              <View style={styles.bannerTextCol}>
                <Text style={styles.bannerDistance}>
                  {formatDistanceToTurn(guidance?.distanceToManeuverMeters ?? 0, units)}
                </Text>
                <Text style={styles.bannerInstruction} numberOfLines={2}>
                  {banner?.primary ?? "Continue on route"}
                </Text>
                {banner?.thenPrimary && (
                  <View style={styles.thenChipWrap}>
                    <Chip
                      label="Then"
                      tone="neutral"
                      icon={
                        banner.thenManeuverType ? (
                          <ManeuverIcon type={banner.thenManeuverType} size={16} color={theme.textSecondary} />
                        ) : undefined
                      }
                    />
                  </View>
                )}
              </View>
            </>
          ) : (
            <View style={styles.bannerFallbackRow}>
              <ActivityIndicator color={theme.signal} />
              <Text style={styles.reroutingText}>
                {bannerState === "rerouting" ? "Rerouting…" : "Starting guidance…"}
              </Text>
            </View>
          )}

          <Pressable
            onPress={onToggleVoiceMuted}
            accessibilityRole="button"
            accessibilityLabel={voiceMuted ? "Unmute voice guidance" : "Mute voice guidance"}
            hitSlop={8}
            style={styles.voiceButton}
          >
            <SpeakerIcon muted={voiceMuted} color={theme.textSecondary} />
          </Pressable>
        </View>
        </Card>
        {laneBarVisible && lanes && (
          <View style={styles.laneBarWrap}>
            <Card style={styles.laneBarCard} padded={false}>
              <LaneBar lanes={lanes} />
            </Card>
          </View>
        )}
      </View>

      <View
        style={styles.bottomBarWrap}
        onLayout={(e) => setBottomBarHeight(e.nativeEvent.layout.height)}
      >
        <Card style={[styles.bottomBar, { paddingBottom: insets.bottom + spacing[3] }]}>
        <View style={styles.bottomRow}>
          <Pressable style={styles.etaColumn} onPress={handleEtaTap} accessibilityRole="button">
            <Text style={styles.minutesRemaining}>
              {guidance ? formatDuration(guidance.remainingDurationSeconds) : "—"}
            </Text>
            <Text style={styles.etaSubtitle} numberOfLines={1}>
              {guidance
                ? `${formatDistance(guidance.remainingDistanceMeters, units)} · Arrives ${formatClockTime(guidance.etaEpochMs)}`
                : place.name}
            </Text>
            {simulateDriveEnabled && onSimulateDrive && (
              <Text style={styles.simulateLabel} onPress={onSimulateDrive}>
                Simulate drive
              </Text>
            )}
          </Pressable>

          <Pressable
            onPress={onToggleOverview}
            accessibilityRole="button"
            accessibilityState={{ selected: isOverview }}
            style={styles.overviewToggle}
          >
            <Text style={styles.overviewToggleText}>{isOverview ? "Chase" : "Overview"}</Text>
          </Pressable>

          <Button label="Exit" variant="destructive" onPress={() => setExitConfirmOpen(true)} style={styles.exitButton} />
        </View>
        </Card>
      </View>

      {onNavModeChange && (
        <GuidanceSettingsSheet
          visible={settingsOpen}
          onClose={() => setSettingsOpen(false)}
          navMode={navMode}
          onNavModeChange={onNavModeChange}
          pitchDeg={pitchDeg}
          onPitchChange={setPitchDeg}
          puckStyle={puckStyle}
          onPuckStyleChange={setPuckStyle}
        />
      )}

      {exitConfirmOpen && (
        <View style={styles.modalScrim}>
          <Pressable
            style={StyleSheet.absoluteFill}
            onPress={() => setExitConfirmOpen(false)}
            accessibilityLabel="Dismiss"
          />
          <Card style={styles.modalCard}>
            <Text style={styles.modalTitle}>End navigation?</Text>
            <Text style={styles.modalBody}>This stops turn-by-turn guidance.</Text>
            <View style={styles.modalButtons}>
              <Button
                label="Keep navigating"
                variant="secondary"
                onPress={() => setExitConfirmOpen(false)}
                style={styles.modalButton}
              />
              <Button
                label="Exit"
                variant="destructive"
                onPress={onExit}
                style={styles.modalButton}
              />
            </View>
          </Card>
        </View>
      )}
    </View>
  );
}

function GearIcon(): React.JSX.Element {
  return (
    <Svg width={20} height={20} viewBox="0 0 24 24" accessibilityLabel="Options">
      <Circle cx={12} cy={12} r={3} fill={theme.textPrimary} />
      <Path
        d="M12 2.5v3M12 18.5v3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M2.5 12h3M18.5 12h3M4.9 19.1 7 17M17 7l2.1-2.1"
        stroke={theme.textPrimary}
        strokeWidth={2}
        strokeLinecap="round"
      />
    </Svg>
  );
}

const styles = StyleSheet.create({
  fill: {
    flex: 1,
    backgroundColor: theme.background,
  },
  bannerCard: {
    position: "absolute",
    left: spacing[3],
    right: spacing[3],
  },
  laneBarWrap: {
    marginTop: spacing[2],
  },
  laneBarCard: {
    paddingVertical: spacing[2],
    paddingHorizontal: spacing[3],
  },
  laneBarRow: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
    gap: spacing[2],
  },
  laneCell: {
    minWidth: 36,
    height: 36,
    borderRadius: radius.sm,
    alignItems: "center",
    justifyContent: "center",
    paddingHorizontal: spacing[1],
  },
  laneCellActive: {
    backgroundColor: theme.signalMuted,
  },
  laneCellInactive: {
    backgroundColor: theme.surfaceAlt,
    opacity: 0.6,
  },
  bannerRow: {
    flexDirection: "row",
    alignItems: "center",
    gap: spacing[3],
  },
  bannerFallbackRow: {
    flex: 1,
    flexDirection: "row",
    alignItems: "center",
    gap: spacing[3],
    paddingVertical: spacing[2],
  },
  reroutingText: {
    fontSize: typeScale.body.fontSize,
    color: theme.textSecondary,
  },
  bannerIconWrap: {
    width: 52,
    height: 52,
    borderRadius: radius.md,
    backgroundColor: theme.surfaceAlt,
    alignItems: "center",
    justifyContent: "center",
  },
  bannerTextCol: {
    flex: 1,
    gap: 2,
  },
  bannerDistance: {
    fontSize: typeScale.headline.fontSize,
    fontWeight: typeScale.headline.fontWeight,
    color: theme.textPrimary,
  },
  bannerInstruction: {
    fontSize: typeScale.body.fontSize,
    color: theme.textPrimary,
  },
  thenChipWrap: {
    marginTop: spacing[1],
  },
  voiceButton: {
    width: minTouchTarget - 8,
    height: minTouchTarget - 8,
    borderRadius: radius.pill,
    alignItems: "center",
    justifyContent: "center",
    backgroundColor: theme.surfaceAlt,
  },
  recentreWrap: {
    position: "absolute",
    alignSelf: "center",
  },
  roadWrap: {
    position: "absolute",
    left: 84,
    right: 64,
    alignItems: "center",
  },
  roadPill: {
    maxWidth: "100%",
    paddingHorizontal: spacing[3],
    paddingVertical: spacing[2],
    borderRadius: radius.pill,
    backgroundColor: theme.surface,
    ...theme.shadow,
  },
  roadText: {
    fontSize: typeScale.label.fontSize,
    fontWeight: typeScale.label.fontWeight,
    color: theme.textPrimary,
    textAlign: "center",
  },
  modalScrim: {
    ...StyleSheet.absoluteFill,
    backgroundColor: "rgba(16,24,42,0.5)",
    alignItems: "center",
    justifyContent: "center",
    paddingHorizontal: spacing[6],
  },
  modalCard: {
    width: "100%",
    maxWidth: 380,
    gap: spacing[3],
  },
  modalTitle: {
    fontSize: typeScale.headline.fontSize,
    lineHeight: typeScale.headline.lineHeight,
    fontWeight: typeScale.headline.fontWeight,
    color: theme.textPrimary,
  },
  modalBody: {
    fontSize: typeScale.body.fontSize,
    color: theme.textSecondary,
  },
  modalButtons: {
    flexDirection: "row",
    gap: spacing[3],
    marginTop: spacing[2],
  },
  modalButton: {
    flex: 1,
  },
  followPuck: {
    position: "absolute",
    alignItems: "center",
    justifyContent: "center",
  },
  followPuckImage: {
    width: "100%",
    height: "100%",
    resizeMode: "contain",
  },
  optionsFab: {
    position: "absolute",
    right: spacing[3],
    width: minTouchTarget,
    height: minTouchTarget,
    borderRadius: radius.pill,
    backgroundColor: theme.surface,
    alignItems: "center",
    justifyContent: "center",
    ...theme.shadow,
  },
  optionsDot: {
    position: "absolute",
    top: 8,
    right: 8,
    width: 8,
    height: 8,
    borderRadius: 4,
    backgroundColor: theme.signal,
  },
  speedWrap: {
    position: "absolute",
    left: spacing[3],
    flexDirection: "column",
    alignItems: "center",
    gap: spacing[2],
  },
  speedPill: {
    minWidth: 64,
    alignItems: "center",
    justifyContent: "center",
    paddingHorizontal: spacing[3],
    paddingVertical: spacing[2],
    borderRadius: radius.lg,
    backgroundColor: theme.surface,
    ...theme.shadow,
  },
  speedPillOver: {
    backgroundColor: theme.destructive,
  },
  speedValue: {
    fontSize: typeScale.headline.fontSize,
    lineHeight: typeScale.headline.lineHeight,
    fontWeight: "700",
    color: theme.textPrimary,
  },
  speedUnit: {
    fontSize: typeScale.caption.fontSize,
    color: theme.textSecondary,
  },
  speedTextOver: {
    color: theme.onDestructive,
  },
  speedLimitRoundel: {
    width: 48,
    height: 48,
    borderRadius: radius.pill,
    backgroundColor: theme.surface,
    borderWidth: 4,
    borderColor: theme.destructive,
    alignItems: "center",
    justifyContent: "center",
    ...theme.shadow,
  },
  speedLimitValue: {
    fontSize: typeScale.body.fontSize,
    fontWeight: "700",
    color: theme.textPrimary,
  },
  bottomBarWrap: {
    position: "absolute",
    left: 0,
    right: 0,
    bottom: 0,
  },
  bottomBar: {
    borderBottomLeftRadius: 0,
    borderBottomRightRadius: 0,
  },
  bottomRow: {
    flexDirection: "row",
    alignItems: "center",
    gap: spacing[3],
  },
  etaColumn: {
    flex: 1,
  },
  minutesRemaining: {
    fontSize: typeScale.title.fontSize,
    lineHeight: typeScale.title.lineHeight,
    fontWeight: typeScale.title.fontWeight,
    color: theme.textPrimary,
  },
  etaSubtitle: {
    fontSize: typeScale.body.fontSize,
    color: theme.textSecondary,
  },
  simulateLabel: {
    marginTop: spacing[1],
    fontSize: typeScale.caption.fontSize,
    color: theme.signal,
  },
  overviewToggle: {
    minHeight: minTouchTarget,
    paddingHorizontal: spacing[3],
    borderRadius: radius.pill,
    backgroundColor: theme.surfaceAlt,
    alignItems: "center",
    justifyContent: "center",
  },
  overviewToggleText: {
    fontSize: typeScale.label.fontSize,
    fontWeight: typeScale.label.fontWeight,
    color: theme.textPrimary,
  },
  exitButton: {
    paddingHorizontal: spacing[4],
  },
});
