import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BackHandler, Share, StyleSheet, View } from "react-native";
import { StatusBar } from "expo-status-bar";
import {
  activateKeepAwakeAsync,
  deactivateKeepAwake,
} from "expo-keep-awake";
import * as Location from "expo-location";
import { NavigationContainer } from "@react-navigation/native";
import {
  createNativeStackNavigator,
  NativeStackScreenProps,
} from "@react-navigation/native-stack";
import { SafeAreaProvider } from "react-native-safe-area-context";

import { AppModeProvider, useAppMode } from "./src/state/appMode";
import { SettingsProvider, useSettings } from "./src/state/settings";
import { RecentsProvider, useRecents } from "./src/state/recents";
import { SavedPlacesProvider, useSavedPlaces } from "./src/state/saved";
import type {
  RouteAvoid,
  TruckOptions,
} from "./modules/mapmap-react-native/src/MapmapNav.types";

/** Default lorry for the truck/ADR profile — a 44t artic. */
const DEFAULT_TRUCK: TruckOptions = {
  heightM: 4.2,
  weightT: 44,
  lengthM: 16.5,
  widthM: 2.55,
};
import { TerritoryProvider, useTerritory } from "./src/state/territory";
import { useMapmapNav } from "./src/state/mapmapNav";
import { isMockActive } from "./src/state/mapmapNav/useMapmapNav";
import { buildOnlineStyle } from "./src/map/onlineStyle";
import { fetchIsochrone } from "./src/map/gatewayIsochrone";
import { DemoDriver } from "./src/demo/DemoDriver";
import { useSpokenGuidance } from "./src/state/voice/useSpokenGuidance";
import { speakSample } from "./src/state/voice/speak";
import { useLiveGuidance } from "./src/state/guidance/useLiveGuidance";
import { useSimulatedGuidance } from "./src/state/guidance/useSimulatedGuidance";
import { useSpeedLimits } from "./src/state/guidance/useSpeedLimits";
import { speedLimitKphAtFraction } from "./src/state/guidance/speedLimits";
import { reverseGeocode } from "./src/state/search/reverseGeocode";
import { countryForTerritory, resolvePostalCode } from "./src/state/search/postalCode";
import type {
  BridgeError,
  GuidanceEvent,
  Route,
  RouteProfile,
  RouteResult,
  SearchResult,
  TerritoryInfo,
  TerritorySummary,
} from "./src/state/types";
import {
  AboutScreen,
  ArrivedCard,
  ExploreScreen,
  GuidanceScreen,
  OnboardingLocationScreen,
  OnboardingTerritoryScreen,
  OnboardingWelcomeScreen,
  PlaceCard,
  RoutePreview,
  SearchSheet,
  SettingsScreen,
  SplashScreen,
  TerritoriesScreen,
} from "./src/screens";
import type { OnboardingTerritoryInstallState } from "./src/screens";

/**
 * API key wiring is Fable Phase 3 integration work (CONTRACTS.md
 * "Integration"), not T4/T5 scope — init() is called with an empty key so
 * the bridge (real or mock) reaches a usable state in dev/demo builds.
 */
const MAPMAP_API_KEY = process.env.EXPO_PUBLIC_MAPMAP_API_KEY ?? "";

/* ================= navigation param lists ================= */

type RootStackParamList = {
  Main: undefined;
  Settings: undefined;
  Territories: undefined;
  About: undefined;
};

type OnboardingStackParamList = {
  Welcome: undefined;
  Location: undefined;
  Territory: undefined;
};

const RootStack = createNativeStackNavigator<RootStackParamList>();
const OnboardingStack = createNativeStackNavigator<OnboardingStackParamList>();

/* ================= Main — the map-first mode surface ================= */
/**
 * "One map surface" per the UI spec: ExploreScreen is always mounted as the
 * base layer; the current AppMode decides which sheet/card floats on top.
 * SEARCH/PLACE/PREVIEW/GUIDANCE/ARRIVED each get a thin container here that
 * wires the state machine + useMapmapNav to the matching S4–S8 placeholder,
 * so T6–T8 inherit a working data flow, not just an empty prop shape.
 */
function Overlay({ children }: { children: React.ReactNode }): React.JSX.Element {
  return (
    <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
      {children}
    </View>
  );
}

function SearchContainer(): React.JSX.Element {
  const { state, dispatch } = useAppMode();
  const mapmapNav = useMapmapNav();
  const { settings } = useSettings();
  const { recents, addRecent, clearRecents } = useRecents();
  const { home, work, saved, addSaved, removeSaved } = useSavedPlaces();
  const { activeTerritoryId } = useTerritory();
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isSearching, setIsSearching] = useState(false);
  const query = state.mode === "SEARCH" ? state.query : "";
  const favourites = saved.filter((p) => p.kind === "favourite");

  useEffect(() => {
    if (state.mode !== "SEARCH") return;
    if (!query.trim()) {
      setResults([]);
      return;
    }
    setIsSearching(true);
    const timer = setTimeout(() => {
      // Run POI/place search and postal-code resolution together. MapMap search
      // can't resolve postal codes yet (see resolvePostalCode); the country
      // comes from the active territory (UK-first default when on online tiles).
      // When the postal lookup hits, surface it at the top.
      const country = countryForTerritory(activeTerritoryId);
      Promise.all([
        mapmapNav.search(query, undefined, 20).catch(() => [] as SearchResult[]),
        resolvePostalCode(query, country).catch(() => null),
      ])
        .then(([poi, postcode]) => {
          if (!postcode) {
            setResults(poi);
            return;
          }
          const deduped = poi.filter((r) => r.name !== postcode.name);
          setResults([postcode, ...deduped].slice(0, 20));
        })
        .finally(() => setIsSearching(false));
    }, 250);
    return () => clearTimeout(timer);
  }, [query, state.mode, mapmapNav, activeTerritoryId]);

  return (
    <SearchSheet
      query={query}
      onQueryChange={(q) => dispatch({ type: "UPDATE_SEARCH_QUERY", query: q })}
      results={results}
      recents={recents}
      isSearching={isSearching}
      onSelectResult={(result) => {
        addRecent(result);
        dispatch({ type: "SELECT_PLACE", place: result });
      }}
      onClearRecents={clearRecents}
      onCancel={() => dispatch({ type: "CANCEL_SEARCH" })}
      units={settings.units}
      home={home}
      work={work}
      favourites={favourites}
      onSetSaved={(kind) => {
        // Save the top result (or first recent) as home/work — a lightweight
        // "set from what you just searched" affordance.
        const candidate = results[0] ?? recents[0];
        if (candidate) addSaved(candidate, kind);
      }}
      onRemoveSaved={removeSaved}
    />
  );
}

function PlaceContainer({
  place,
}: {
  place: SearchResult;
}): React.JSX.Element {
  const { dispatch } = useAppMode();
  const { settings } = useSettings();
  const { addSaved } = useSavedPlaces();
  const [expanded, setExpanded] = useState(false);

  // A map tap with no POI under it arrives as a "Dropped pin" + raw coords.
  // Reverse-geocode it to a real street/address (position is unchanged, so
  // routing still targets exactly where the user tapped).
  const [enriched, setEnriched] = useState<SearchResult | null>(null);
  useEffect(() => {
    setEnriched(null);
    if (place.name !== "Dropped pin") return;
    let cancelled = false;
    void reverseGeocode(MAPMAP_API_KEY, place.position).then((r) => {
      if (!cancelled && r) setEnriched({ ...place, name: r.name, address: r.address });
    });
    return () => {
      cancelled = true;
    };
  }, [place]);
  const shown = enriched ?? place;

  const coords = `${shown.position.latitude.toFixed(5)}, ${shown.position.longitude.toFixed(5)}`;

  return (
    <PlaceCard
      place={shown}
      expanded={expanded}
      onExpandedChange={setExpanded}
      onDirections={() => dispatch({ type: "REQUEST_DIRECTIONS" })}
      onDismiss={() => dispatch({ type: "BACK" })}
      onSave={() => addSaved(shown, "favourite")}
      onShare={() => {
        void Share.share({
          message: shown.address
            ? `${shown.name} — ${shown.address} (${coords})`
            : `${shown.name} (${coords})`,
        });
      }}
      units={settings.units}
    />
  );
}

type LatLng = { latitude: number; longitude: number };

/**
 * Real device position as the route's origin, when permission is granted:
 * last known first (instant, no GPS warm-up wait), falling back to a fresh
 * fix. Falls back to a synthetic point ~1.1 km from the destination when
 * permission is denied or location genuinely can't be resolved (also how
 * simulator demos run, per the original T4/T5 note this replaces) — origin
 * === destination would reject computeRoute() with E_NO_ROUTE.
 */
async function resolveRouteOrigin(destination: LatLng): Promise<LatLng> {
  const synthetic: LatLng = {
    latitude: destination.latitude - 0.01,
    longitude: destination.longitude,
  };
  try {
    const { status } = await Location.requestForegroundPermissionsAsync();
    if (status !== Location.PermissionStatus.GRANTED) return synthetic;

    const last = await Location.getLastKnownPositionAsync();
    if (last) {
      return { latitude: last.coords.latitude, longitude: last.coords.longitude };
    }
    const current = await Location.getCurrentPositionAsync();
    return { latitude: current.coords.latitude, longitude: current.coords.longitude };
  } catch {
    return synthetic;
  }
}

function PreviewContainer({
  place,
  profile,
  routeResult,
  activeRouteIndex,
  isComputing,
  error,
  styleJson,
  onShowAbout,
}: {
  place: SearchResult;
  profile: RouteProfile;
  routeResult?: RouteResult;
  activeRouteIndex: number;
  isComputing: boolean;
  error?: BridgeError;
  styleJson?: string | null;
  onShowAbout: () => void;
}): React.JSX.Element {
  const { dispatch } = useAppMode();
  const { settings } = useSettings();
  const mapmapNav = useMapmapNav();
  const [truckOptions, setTruckOptions] = useState<TruckOptions>(DEFAULT_TRUCK);
  const [avoid, setAvoid] = useState<RouteAvoid>({});

  const computeRoute = useCallback(() => {
    dispatch({ type: "ROUTE_COMPUTING" });
    let cancelled = false;
    resolveRouteOrigin(place.position).then((origin) => {
      if (cancelled) return;
      mapmapNav
        .computeRoute({
          profile,
          origin,
          destination: place.position,
          truck: profile === "truck" ? truckOptions : undefined,
          avoid,
        })
        .then((result) => {
          if (!cancelled) dispatch({ type: "ROUTE_COMPUTED", result });
        })
        .catch((err: BridgeError) => {
          if (!cancelled) dispatch({ type: "ROUTE_FAILED", error: err });
        });
    });
    return () => {
      cancelled = true;
    };
  }, [dispatch, mapmapNav, profile, place.position, truckOptions, avoid]);

  useEffect(() => {
    const cancel = computeRoute();
    return cancel;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [profile, place, truckOptions, avoid]);

  return (
    <RoutePreview
      place={place}
      profile={profile}
      onProfileChange={(next) => dispatch({ type: "CHANGE_PROFILE", profile: next })}
      truckOptions={truckOptions}
      onTruckOptionsChange={setTruckOptions}
      avoid={avoid}
      onAvoidChange={setAvoid}
      routeResult={routeResult}
      activeRouteIndex={activeRouteIndex}
      onSelectRoute={(index) => dispatch({ type: "SELECT_ROUTE", routeIndex: index })}
      isComputing={isComputing}
      error={error}
      onRetry={() => {
        computeRoute();
      }}
      onStart={() => dispatch({ type: "START" })}
      onOverview={() => {}}
      onBack={() => dispatch({ type: "BACK" })}
      styleJson={styleJson}
      units={settings.units}
      onShowAbout={onShowAbout}
    />
  );
}

function GuidanceContainer({
  place,
  routeIndex,
  route,
  profile,
  guidance,
  isRerouting,
  isFollowingCamera,
  isOverview,
  styleJson,
  onShowAbout,
}: {
  place: SearchResult;
  routeIndex: number;
  route?: Route;
  profile: RouteProfile;
  guidance?: GuidanceEvent;
  isRerouting: boolean;
  isFollowingCamera: boolean;
  isOverview: boolean;
  styleJson?: string | null;
  onShowAbout: () => void;
}): React.JSX.Element {
  const { dispatch } = useAppMode();
  const { settings, updateSettings } = useSettings();
  const mapmapNav = useMapmapNav();

  // Audible turn instructions. On the real bridge the SDK's VoiceGuidance
  // speaks natively; this hook only speaks when the JS mock is active.
  useSpokenGuidance(guidance, settings.voiceMuted, settings.volume);

  useEffect(() => {
    if (settings.keepAwake) void activateKeepAwakeAsync("guidance");
    return () => {
      if (settings.keepAwake) void deactivateKeepAwake("guidance");
    };
  }, [settings.keepAwake]);

  // Real GPS is the DEFAULT drive mode — the puck follows the device and only
  // advances when the phone actually moves. "Simulate" is the explicit demo.
  // Both feed the same route-progress engine, so the ETA/speed come from the
  // route's real duration (not a fixed cruise speed) either way.
  const [navMode, setNavMode] = useState<"live" | "simulate">("live");

  const handleGuidanceEvent = useCallback(
    (event: GuidanceEvent) => {
      dispatch({ type: "GUIDANCE_UPDATE", event });
      if (event.state === "arrived") {
        dispatch({
          type: "ARRIVE",
          journey: {
            durationSeconds: route?.durationSeconds ?? event.remainingDurationSeconds,
            distanceMeters: route?.distanceMeters ?? event.remainingDistanceMeters,
          },
        });
      }
    },
    [dispatch, route]
  );

  // Mid-drive rerouting: when the driver has been off-route for a few fixes,
  // useLiveGuidance calls this with their current position. Recompute a route
  // from there to the destination and swap it in. A ref lockout prevents piling
  // up requests while one is in flight (e.g. circling an ambiguous junction).
  const rerouteInFlight = useRef(false);
  const handleReroute = useCallback(
    (from: LatLng) => {
      if (rerouteInFlight.current) return;
      rerouteInFlight.current = true;
      mapmapNav
        .computeRoute({ profile, origin: from, destination: place.position })
        .then((result) => {
          const next = result.routes[0];
          if (next) dispatch({ type: "REROUTE_COMPLETE", route: next });
        })
        .catch(() => {
          // Keep the old route; the next sustained deviation will retry.
        })
        .finally(() => {
          rerouteInFlight.current = false;
        });
    },
    [mapmapNav, profile, place.position, dispatch]
  );

  useLiveGuidance(route, navMode === "live", handleGuidanceEvent, handleReroute);
  useSimulatedGuidance(route, navMode === "simulate", handleGuidanceEvent);

  // Posted speed limit at the puck: fetch the route's per-edge limits once
  // (/trace_attributes), then read the current edge by how far along we are.
  const speedTrack = useSpeedLimits(route, MAPMAP_API_KEY);
  const speedLimitKph = useMemo(() => {
    if (!speedTrack || !route || route.distanceMeters <= 0 || !guidance) return undefined;
    if (guidance.state !== "navigating") return undefined;
    const travelled = route.distanceMeters - guidance.remainingDistanceMeters;
    const fraction = travelled / route.distanceMeters;
    return speedLimitKphAtFraction(speedTrack, fraction) ?? undefined;
  }, [speedTrack, route, guidance]);

  return (
    <GuidanceScreen
      place={place}
      guidance={guidance}
      isRerouting={isRerouting}
      isFollowingCamera={isFollowingCamera}
      isOverview={isOverview}
      voiceMuted={settings.voiceMuted}
      onToggleVoiceMuted={() => {
        const next = !settings.voiceMuted;
        updateSettings({ voiceMuted: next });
        void mapmapNav.setVoiceMuted(next);
      }}
      onRecentre={() => dispatch({ type: "SET_FOLLOWING_CAMERA", following: true })}
      onToggleOverview={() => dispatch({ type: "TOGGLE_OVERVIEW" })}
      onExit={() => dispatch({ type: "EXIT" })}
      styleJson={styleJson}
      route={route}
      units={settings.units}
      speedLimitKph={speedLimitKph}
      onShowAbout={onShowAbout}
      navMode={navMode}
      onNavModeChange={setNavMode}
    />
  );
}

function ArrivedContainer({
  place,
  journey,
}: {
  place: SearchResult;
  journey: { durationSeconds: number; distanceMeters: number };
}): React.JSX.Element {
  const { dispatch } = useAppMode();
  const { settings } = useSettings();
  return (
    <ArrivedCard
      place={place}
      journey={journey}
      units={settings.units}
      onDone={() => dispatch({ type: "DONE" })}
    />
  );
}

/**
 * MainScreen fetches the active territory's MapLibre style once (lifted up
 * from ExploreScreen — see FINDINGS.md) and threads it, plus units and the
 * About navigation callback, down to whichever of Explore/Preview/Guidance
 * is on screen.
 */
function MainScreen({
  navigation,
}: NativeStackScreenProps<RootStackParamList, "Main">): React.JSX.Element {
  const { state, dispatch } = useAppMode();
  const { activeTerritoryId, mapSource, setMapSource } = useTerritory();
  const { settings, updateSettings } = useSettings();
  const mapmapNav = useMapmapNav();
  const [isFollowingUser, setIsFollowingUser] = useState(true);
  const [styleJson, setStyleJson] = useState<string | null>(null);

  // A place picked via search or map-tap, shown as a pin on Explore.
  const selectedPlace = "place" in state ? state.place : null;

  // Offline needs a real signed territory with local tiles. On the mock
  // (demo / no native build) there are none — a real signed-territory
  // install is also currently blocked platform-side (FINDINGS F2) — so
  // render the online basemap whenever we can't produce genuine offline
  // tiles, and never leave the map blank.
  const usingOnlineTiles = mapSource === "online" || isMockActive();

  useEffect(() => {
    let cancelled = false;
    if (usingOnlineTiles) {
      // Hosted vector tiles — a real map with no download (FINDINGS F2).
      buildOnlineStyle(MAPMAP_API_KEY, "uk", {
        buildings3d: settings.show3dBuildings,
        pois: settings.showPois,
        theme: settings.mapTheme,
      })
        .then((style) => {
          if (!cancelled) setStyleJson(style);
        })
        .catch(() => {
          if (!cancelled) setStyleJson(null);
        });
    } else if (activeTerritoryId) {
      mapmapNav
        .getTerritoryStyle(activeTerritoryId, "light")
        .then((style) => {
          if (!cancelled) setStyleJson(style);
        })
        .catch(() => {
          if (!cancelled) setStyleJson(null);
        });
    } else {
      setStyleJson(null);
    }
    return () => {
      cancelled = true;
    };
  }, [
    activeTerritoryId,
    usingOnlineTiles,
    mapmapNav,
    settings.show3dBuildings,
    settings.showPois,
    settings.mapTheme,
  ]);

  const onShowAbout = useCallback(() => {
    navigation.navigate("About");
  }, [navigation]);

  // Android hardware back: the mode overlays (search/place/preview/guidance/
  // arrived) float over the always-mounted map — they aren't navigation
  // screens, so without this, back would exit the app instead of stepping
  // back a mode. EXPLORE returns false to let the OS handle it (leave app).
  useEffect(() => {
    const sub = BackHandler.addEventListener("hardwareBackPress", () => {
      switch (state.mode) {
        case "SEARCH":
          dispatch({ type: "CANCEL_SEARCH" });
          return true;
        case "PLACE":
        case "PREVIEW":
          dispatch({ type: "BACK" });
          return true;
        case "GUIDANCE":
          dispatch({ type: "EXIT" });
          return true;
        case "ARRIVED":
          dispatch({ type: "DONE" });
          return true;
        default:
          return false;
      }
    });
    return () => sub.remove();
  }, [state.mode, dispatch]);

  return (
    <View style={styles.fill}>
      <ExploreScreen
        offlineTerritoryLabel={
          usingOnlineTiles
            ? "Online maps"
            : activeTerritoryId
              ? `${activeTerritoryId.toUpperCase()} · offline`
              : "Offline"
        }
        isFollowingUser={isFollowingUser}
        hasLocationPermission
        styleJson={styleJson}
        selectedPlace={selectedPlace}
        show3dBuildings={settings.show3dBuildings}
        showPois={settings.showPois}
        mapTheme={settings.mapTheme}
        mapSource={mapSource}
        onSearchPress={() => dispatch({ type: "OPEN_SEARCH" })}
        onSettingsPress={() => navigation.navigate("Settings")}
        onLocateMePress={() => setIsFollowingUser((f) => !f)}
        onTerritoryBadgePress={() => navigation.navigate("Territories")}
        onPlaceSelectedOnMap={(place) => dispatch({ type: "SELECT_PLACE", place })}
        onLayerChange={updateSettings}
        onMapSourceChange={setMapSource}
        onReachableArea={(center) =>
          fetchIsochrone(MAPMAP_API_KEY, center, "car", [
            { minutes: 20, color: "#3A86FF" },
            { minutes: 10, color: "#1F438A" },
          ])
        }
        onShowAbout={onShowAbout}
      />

      {state.mode === "SEARCH" && (
        <Overlay>
          <SearchContainer />
        </Overlay>
      )}

      {state.mode === "PLACE" && (
        <Overlay>
          <PlaceContainer place={state.place} />
        </Overlay>
      )}

      {state.mode === "PREVIEW" && (
        <Overlay>
          <PreviewContainer
            place={state.place}
            profile={state.profile}
            routeResult={state.routeResult}
            activeRouteIndex={state.activeRouteIndex}
            isComputing={state.isComputing}
            error={state.error}
            styleJson={styleJson}
            onShowAbout={onShowAbout}
          />
        </Overlay>
      )}

      {state.mode === "GUIDANCE" && (
        <Overlay>
          <GuidanceContainer
            place={state.place}
            routeIndex={state.routeIndex}
            route={state.route}
            profile={state.profile}
            guidance={state.guidance}
            isRerouting={state.isRerouting}
            isFollowingCamera={state.isFollowingCamera}
            isOverview={state.isOverview}
            styleJson={styleJson}
            onShowAbout={onShowAbout}
          />
        </Overlay>
      )}

      {state.mode === "ARRIVED" && (
        <Overlay>
          <ArrivedContainer place={state.place} journey={state.journey} />
        </Overlay>
      )}
    </View>
  );
}

/* ================= Settings / Territories / About (pushed) ================= */

function SettingsContainer({
  navigation,
}: NativeStackScreenProps<RootStackParamList, "Settings">): React.JSX.Element {
  const { settings, updateSettings } = useSettings();
  const mapmapNav = useMapmapNav();

  const { mapSource, setMapSource } = useTerritory();

  return (
    <SettingsScreen
      settings={settings}
      onUpdateSettings={updateSettings}
      onTestVoice={() => {
        void mapmapNav.setVoiceVolume(settings.volume);
        void mapmapNav.setVoiceMuted(false);
        speakSample(settings.volume);
      }}
      onNavigateTerritories={() => navigation.navigate("Territories")}
      onNavigateAbout={() => navigation.navigate("About")}
      mapSource={mapSource}
      onMapSourceChange={setMapSource}
    />
  );
}

function TerritoriesContainer(): React.JSX.Element {
  const mapmapNav = useMapmapNav();
  const { setActiveTerritory } = useTerritory();
  const [installed, setInstalled] = useState<TerritoryInfo[]>([]);

  const refresh = useCallback(() => {
    mapmapNav
      .listInstalledTerritories()
      .then(setInstalled)
      .catch(() => setInstalled([]));
  }, [mapmapNav]);

  useEffect(refresh, [refresh]);

  const storageUsedBytes = installed.reduce((sum, t) => sum + t.sizeOnDiskBytes, 0);

  return (
    <TerritoriesScreen
      installed={installed}
      storageUsedBytes={storageUsedBytes}
      onCheckUpdate={() => refresh()}
      onDelete={(territoryId) => {
        void mapmapNav.removeTerritory(territoryId).then(refresh);
      }}
      onAddTerritory={() => {}}
      onSetActive={(territoryId) => setActiveTerritory(territoryId)}
    />
  );
}

function AboutContainer(): React.JSX.Element {
  return (
    <AboutScreen
      // T9 owns app.json/eas.json — wire real version/build once that lands.
      appVersion="1.0.0"
      buildNumber="dev"
      sdkVersion="0.2.0"
      onOpenMapmapAi={() => {}}
      onOpenPrivacyPolicy={() => {}}
    />
  );
}

/* ================= Onboarding (first run only) ================= */

function OnboardingTerritoryContainer(): React.JSX.Element {
  const mapmapNav = useMapmapNav();
  const { setActiveTerritory } = useTerritory();
  const [territories, setTerritories] = useState<TerritorySummary[]>([]);
  const [installState, setInstallState] =
    useState<OnboardingTerritoryInstallState | null>(null);

  useEffect(() => {
    let cancelled = false;
    // init() already resolved before this screen is reachable — see
    // AppShell's bootstrap gate below, which every route (onboarding or
    // main) mounts behind.
    mapmapNav
      .listAvailableTerritories()
      .then((list) => {
        if (!cancelled) setTerritories(list);
      })
      .catch(() => undefined);

    const sub = mapmapNav.addListener("territoryProgress", (event) => {
      setInstallState({
        territoryId: event.territoryId,
        phase: event.phase,
        bytesDone: event.bytesDone,
        bytesTotal: event.bytesTotal,
        error: event.error,
      });
    });

    return () => {
      cancelled = true;
      sub.remove();
    };
  }, [mapmapNav]);

  const install = useCallback(
    (territoryId: string) => {
      mapmapNav
        .installTerritory(territoryId)
        .then(() => setActiveTerritory(territoryId))
        .catch((error: BridgeError) => {
          setInstallState({
            territoryId,
            phase: "failed",
            bytesDone: 0,
            bytesTotal: 0,
            error,
          });
        });
    },
    [mapmapNav, setActiveTerritory]
  );

  return (
    <OnboardingTerritoryScreen
      territories={territories}
      recommendedTerritoryId="uk"
      installState={installState}
      onInstall={install}
      onRetry={install}
      // The onboarding gate (AppShell below) flips to Main automatically
      // once setActiveTerritory resolves — nothing to do here.
      onDone={() => {}}
    />
  );
}

function OnboardingNavigator(): React.JSX.Element {
  return (
    <OnboardingStack.Navigator screenOptions={{ headerShown: false }}>
      <OnboardingStack.Screen name="Welcome">
        {({ navigation }) => (
          <OnboardingWelcomeScreen
            onGetStarted={() => navigation.navigate("Location")}
          />
        )}
      </OnboardingStack.Screen>
      <OnboardingStack.Screen name="Location">
        {({ navigation }) => (
          <OnboardingLocationScreen
            onAllow={() => navigation.navigate("Territory")}
            onNotNow={() => navigation.navigate("Territory")}
          />
        )}
      </OnboardingStack.Screen>
      <OnboardingStack.Screen
        name="Territory"
        component={OnboardingTerritoryContainer}
      />
    </OnboardingStack.Navigator>
  );
}

function MainNavigator(): React.JSX.Element {
  return (
    <RootStack.Navigator>
      <RootStack.Screen
        name="Main"
        component={MainScreen}
        options={{ headerShown: false }}
      />
      <RootStack.Screen name="Settings" component={SettingsContainer} />
      <RootStack.Screen name="Territories" component={TerritoriesContainer} />
      <RootStack.Screen name="About" component={AboutContainer} />
    </RootStack.Navigator>
  );
}

/**
 * First-run gate: onboarding until a territory is active (UI spec S2),
 * then the main app; re-launches with an active territory skip S2 entirely.
 * Also the bridge bootstrap: init() is called exactly once here, ahead of
 * either route, so every screen — onboarding's Territory step included —
 * can assume the handle is already initialised.
 */
function AppShell(): React.JSX.Element {
  const { ready: territoryReady, hasMap } = useTerritory();
  const mapmapNav = useMapmapNav();
  const [bridgeReady, setBridgeReady] = useState(false);

  useEffect(() => {
    let cancelled = false;
    mapmapNav
      .init(MAPMAP_API_KEY)
      .catch(() => {
        // Spec S12: an invalid/exhausted key only affects online extras —
        // the app stays fully usable offline, so we don't block the gate.
      })
      .finally(() => {
        if (!cancelled) setBridgeReady(true);
      });
    return () => {
      cancelled = true;
    };
  }, [mapmapNav]);

  if (!territoryReady || !bridgeReady) {
    return <SplashScreen />;
  }

  return (
    <NavigationContainer>
      {hasMap ? <MainNavigator /> : <OnboardingNavigator />}
    </NavigationContainer>
  );
}

export default function App(): React.JSX.Element {
  return (
    <SafeAreaProvider>
      <SettingsProvider>
        <RecentsProvider>
          <SavedPlacesProvider>
          <TerritoryProvider>
            <AppModeProvider>
              <AppShell />
              <DemoDriver />
              <StatusBar style="dark" />
            </AppModeProvider>
          </TerritoryProvider>
          </SavedPlacesProvider>
        </RecentsProvider>
      </SettingsProvider>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  fill: {
    flex: 1,
  },
});
