/**
 * `MapmapNavMock` — a pure-TS, no-native simulation of `MapmapNavModule`
 * (see `MapmapNav.types.ts`). Lets UI builders and tests run in Expo Go /
 * Vitest without a native build.
 *
 * Zero runtime dependency on `expo` / `expo-modules-core` / `react-native`:
 * everything imported from those packages here is `import type`, which is
 * erased at compile time. That's what makes this file safe to import from
 * Vitest (see `mock.test.ts`).
 *
 * Simulates:
 *  - `installTerritory`: phased `territoryProgress` events (downloading →
 *    verifying → activating → done) over ~`territoryInstallDurationMs`
 *    (default 3s).
 *  - `computeRoute`: a plausible multi-route result between any two points
 *    — geometry is a densified two-leg "dogleg" polyline (so there's a
 *    turn to narrate), distance/duration derived from it.
 *  - `startGuidance`: `guidance` events at ~1Hz (configurable) walking the
 *    chosen route, with banner changes at each maneuver, one illustrative
 *    reroute blip, and a final `arrived` event.
 *  - Voice: a `voiceState` "speaking" pulse on each banner change.
 */
import type {
  BannerInstruction,
  BridgeError,
  BridgeErrorCode,
  GuidanceEvent,
  GuidanceOptions,
  LaneInfo,
  LatLng,
  Maneuver,
  MapmapNavEvents,
  MapmapNavModule,
  Route,
  RouteProfile,
  RouteRequest,
  RouteResult,
  SearchResult,
  TerritoryInfo,
  TerritoryPhase,
  TerritorySummary,
  VoiceStateEvent,
} from "./MapmapNav.types";
import { SimpleEventEmitter } from "./emitter";
import {
  bearingDegrees,
  cumulativeDistancesAt,
  densifySegment,
  destinationPoint,
  distanceMeters,
  pathLengthMeters,
  pointAtDistance,
  signedTurnDegrees,
} from "./geo";

/** Cruise speed per profile, metres/second (rough real-world averages). */
const PROFILE_CRUISE_SPEED_MPS: Record<RouteProfile, number> = {
  car: 13.4, // ~30 mph
  bicycle: 4.2, // ~15 km/h
  walking: 1.4, // ~5 km/h
  truck: 11.2, // ~25 mph
};

/** Demo territory catalogue — mirrors CONTRACTS.md's "small demo territory" fallback. */
const TERRITORY_CATALOG: TerritorySummary[] = [
  { id: "uk", name: "United Kingdom", sizeBytes: 3_000_000_000, version: "mock-2026.07" },
  { id: "ireland", name: "Ireland", sizeBytes: 450_000_000, version: "mock-2026.07" },
];

export type MapmapNavMockOptions = {
  /** Interval between simulated `guidance` ticks, ms. Default 1000 (1Hz). */
  tickIntervalMs?: number;
  /** Total wall-clock time to simulate a territory install. Default 3000. */
  territoryInstallDurationMs?: number;
  /** Whether to emit one illustrative `rerouting` blip per guidance session. Default true. */
  simulateReroute?: boolean;
  /** Optional real-route source (e.g. the hosted MapMap engine). When set,
   * computeRoute tries it first and only falls back to the synthetic
   * dogleg when it returns null — so demo guidance follows real roads. */
  routeProvider?: (request: RouteRequest) => Promise<RouteResult | null>;
};

type GuidanceSession = {
  active: boolean;
  routeIndex: number;
  route: Route;
  profile: RouteProfile;
  options: GuidanceOptions;
};

/** Real UK places (true coordinates) so demo search feels like a product,
 * not a stub. Matching is substring over name/address/keywords. `kind`
 * distinguishes CITY/TOWN/AREA entries ("place") from landmarks, stations
 * and venues ("poi") — see the ranking in `search()`, which surfaces the
 * place itself above incidental POI matches (e.g. "Manchester" the city
 * before "Manchester Piccadilly" the station). */
const LONDON_PLACES: Array<{
  name: string;
  address: string;
  position: LatLng;
  keywords: string[];
  kind: "place" | "poi";
}> = [
  // --- Cities / towns (kind: "place") — rank above POI matches ---
  { name: "London", address: "City centre, London", position: { latitude: 51.5074, longitude: -0.1278 }, keywords: ["london", "city", "centre", "capital"], kind: "place" },
  { name: "Manchester", address: "City centre, Manchester", position: { latitude: 53.4808, longitude: -2.2426 }, keywords: ["manchester", "city", "centre"], kind: "place" },
  { name: "Brighton", address: "City centre, Brighton", position: { latitude: 50.8225, longitude: -0.1372 }, keywords: ["brighton", "city", "centre", "seafront"], kind: "place" },
  { name: "Worthing", address: "Town centre, Worthing", position: { latitude: 50.8179, longitude: -0.3727 }, keywords: ["worthing", "town", "centre", "seafront"], kind: "place" },
  { name: "Liverpool", address: "City centre, Liverpool", position: { latitude: 53.4084, longitude: -2.9916 }, keywords: ["liverpool", "city", "centre"], kind: "place" },
  { name: "Leeds", address: "City centre, Leeds", position: { latitude: 53.8008, longitude: -1.5491 }, keywords: ["leeds", "city", "centre"], kind: "place" },
  { name: "Birmingham", address: "City centre, Birmingham", position: { latitude: 52.4862, longitude: -1.8904 }, keywords: ["birmingham", "city", "centre"], kind: "place" },
  { name: "Bristol", address: "City centre, Bristol", position: { latitude: 51.4545, longitude: -2.5879 }, keywords: ["bristol", "city", "centre"], kind: "place" },
  { name: "Edinburgh", address: "City centre, Edinburgh", position: { latitude: 55.9533, longitude: -3.1883 }, keywords: ["edinburgh", "city", "centre", "scotland"], kind: "place" },
  { name: "Glasgow", address: "City centre, Glasgow", position: { latitude: 55.8642, longitude: -4.2518 }, keywords: ["glasgow", "city", "centre", "scotland"], kind: "place" },
  { name: "Cambridge", address: "City centre, Cambridge", position: { latitude: 52.2053, longitude: 0.1218 }, keywords: ["cambridge", "city", "centre", "university"], kind: "place" },
  { name: "Oxford", address: "City centre, Oxford", position: { latitude: 51.752, longitude: -1.2577 }, keywords: ["oxford", "city", "centre", "university"], kind: "place" },

  // --- Landmarks / stations / venues (kind: "poi") ---
  { name: "St Pancras International", address: "Euston Road, London N1C 4QP", position: { latitude: 51.532, longitude: -0.1263 }, keywords: ["station", "train", "eurostar", "kings cross"], kind: "poi" },
  { name: "King's Cross Station", address: "Euston Road, London N1 9AL", position: { latitude: 51.5308, longitude: -0.1238 }, keywords: ["station", "train", "rail"], kind: "poi" },
  { name: "British Museum", address: "Great Russell Street, London WC1B 3DG", position: { latitude: 51.5194, longitude: -0.127 }, keywords: ["museum", "history"], kind: "poi" },
  { name: "Tower Bridge", address: "Tower Bridge Road, London SE1 2UP", position: { latitude: 51.5055, longitude: -0.0754 }, keywords: ["bridge", "landmark", "thames"], kind: "poi" },
  { name: "Tower of London", address: "St Katharine's & Wapping, London EC3N 4AB", position: { latitude: 51.5081, longitude: -0.0759 }, keywords: ["castle", "landmark", "crown jewels"], kind: "poi" },
  { name: "Buckingham Palace", address: "Westminster, London SW1A 1AA", position: { latitude: 51.5014, longitude: -0.1419 }, keywords: ["palace", "royal", "landmark"], kind: "poi" },
  { name: "Big Ben", address: "Westminster, London SW1A 0AA", position: { latitude: 51.5007, longitude: -0.1246 }, keywords: ["clock", "landmark", "parliament", "westminster"], kind: "poi" },
  { name: "London Eye", address: "Riverside Building, County Hall, London SE1 7PB", position: { latitude: 51.5033, longitude: -0.1195 }, keywords: ["wheel", "landmark", "attraction"], kind: "poi" },
  { name: "Trafalgar Square", address: "Charing Cross, London WC2N 5DN", position: { latitude: 51.508, longitude: -0.1281 }, keywords: ["square", "nelson", "landmark"], kind: "poi" },
  { name: "Covent Garden", address: "Covent Garden, London WC2E 8RF", position: { latitude: 51.5117, longitude: -0.1226 }, keywords: ["market", "shopping", "theatre"], kind: "poi" },
  { name: "Piccadilly Circus", address: "Piccadilly Circus, London W1D 7ET", position: { latitude: 51.51, longitude: -0.1337 }, keywords: ["square", "lights", "west end"], kind: "poi" },
  { name: "Natural History Museum", address: "Cromwell Road, London SW7 5BD", position: { latitude: 51.4967, longitude: -0.1764 }, keywords: ["museum", "dinosaur", "kensington"], kind: "poi" },
  { name: "Science Museum", address: "Exhibition Road, London SW7 2DD", position: { latitude: 51.4978, longitude: -0.1745 }, keywords: ["museum", "kensington"], kind: "poi" },
  { name: "Tate Modern", address: "Bankside, London SE1 9TG", position: { latitude: 51.5076, longitude: -0.0994 }, keywords: ["gallery", "art", "museum"], kind: "poi" },
  { name: "St Paul's Cathedral", address: "St Paul's Churchyard, London EC4M 8AD", position: { latitude: 51.5138, longitude: -0.0984 }, keywords: ["cathedral", "church", "dome"], kind: "poi" },
  { name: "Hyde Park", address: "London W2 2UH", position: { latitude: 51.5073, longitude: -0.1657 }, keywords: ["park", "green", "serpentine"], kind: "poi" },
  { name: "Regent's Park", address: "London NW1 4NU", position: { latitude: 51.5313, longitude: -0.157 }, keywords: ["park", "green", "zoo"], kind: "poi" },
  { name: "Camden Market", address: "Camden Lock Place, London NW1 8AF", position: { latitude: 51.5413, longitude: -0.1461 }, keywords: ["market", "food", "shopping"], kind: "poi" },
  { name: "The Shard", address: "32 London Bridge Street, London SE1 9SG", position: { latitude: 51.5045, longitude: -0.0865 }, keywords: ["skyscraper", "view", "landmark"], kind: "poi" },
  { name: "Borough Market", address: "8 Southwark Street, London SE1 1TL", position: { latitude: 51.5055, longitude: -0.091 }, keywords: ["market", "food"], kind: "poi" },
  { name: "Waterloo Station", address: "Waterloo Road, London SE1 8SW", position: { latitude: 51.5031, longitude: -0.1132 }, keywords: ["station", "train", "rail"], kind: "poi" },
  { name: "Paddington Station", address: "Praed Street, London W2 1HQ", position: { latitude: 51.5154, longitude: -0.1755 }, keywords: ["station", "train", "rail", "heathrow express"], kind: "poi" },
  { name: "Liverpool Street Station", address: "Liverpool Street, London EC2M 7QH", position: { latitude: 51.5178, longitude: -0.0817 }, keywords: ["station", "train", "rail", "city"], kind: "poi" },
  { name: "Greenwich Observatory", address: "Blackheath Avenue, London SE10 8XJ", position: { latitude: 51.4769, longitude: -0.0005 }, keywords: ["observatory", "meridian", "park"], kind: "poi" },
  { name: "Wembley Stadium", address: "London HA9 0WS", position: { latitude: 51.556, longitude: -0.2795 }, keywords: ["stadium", "football", "concert"], kind: "poi" },
  { name: "Manchester Piccadilly", address: "Piccadilly, Manchester M60 7RA", position: { latitude: 53.4774, longitude: -2.2309 }, keywords: ["station", "train", "rail", "manchester"], kind: "poi" },
  { name: "Old Trafford", address: "Sir Matt Busby Way, Manchester M16 0RA", position: { latitude: 53.4631, longitude: -2.2913 }, keywords: ["stadium", "football", "united", "manchester"], kind: "poi" },
  { name: "Etihad Stadium", address: "Etihad Campus, Manchester M11 3FF", position: { latitude: 53.4831, longitude: -2.2004 }, keywords: ["stadium", "football", "city", "manchester"], kind: "poi" },
  { name: "Manchester Town Hall", address: "Albert Square, Manchester M2 5DB", position: { latitude: 53.4794, longitude: -2.2453 }, keywords: ["landmark", "manchester", "hall"], kind: "poi" },
  { name: "Science and Industry Museum", address: "Liverpool Road, Manchester M3 4FP", position: { latitude: 53.4769, longitude: -2.2551 }, keywords: ["museum", "manchester", "science"], kind: "poi" },
  { name: "Manchester Arndale", address: "Market Street, Manchester M4 3AQ", position: { latitude: 53.4831, longitude: -2.2405 }, keywords: ["shopping", "centre", "manchester"], kind: "poi" },

  // --- Everyday categories (kind: "poi") — back the search category chips
  // (Petrol / Food / Coffee / Parking / Hotels / ATM). Keywords include the
  // chip term so a category tap returns real results, not an empty list. ---
  { name: "Shell", address: "Park Lane, London W1K 7TL", position: { latitude: 51.5045, longitude: -0.1509 }, keywords: ["petrol", "fuel", "fuel station", "shell", "diesel"], kind: "poi" },
  { name: "BP", address: "Bayswater Road, London W2 4RH", position: { latitude: 51.5115, longitude: -0.1857 }, keywords: ["petrol", "fuel", "fuel station", "bp", "diesel"], kind: "poi" },
  { name: "Esso", address: "Kirkstall Road, Leeds LS3 1LX", position: { latitude: 53.8, longitude: -1.58 }, keywords: ["petrol", "fuel", "fuel station", "esso", "diesel"], kind: "poi" },
  { name: "Nando's Soho", address: "10 Frith Street, London W1D 3JF", position: { latitude: 51.5128, longitude: -0.1319 }, keywords: ["food", "restaurant", "chicken", "nandos"], kind: "poi" },
  { name: "Dishoom Covent Garden", address: "12 Upper St Martin's Lane, London WC2H 9FB", position: { latitude: 51.5127, longitude: -0.1266 }, keywords: ["food", "restaurant", "indian", "dinner"], kind: "poi" },
  { name: "Wagamama Trinity Leeds", address: "27 Albion Street, Leeds LS1 5AT", position: { latitude: 53.7965, longitude: -1.544 }, keywords: ["food", "restaurant", "noodles"], kind: "poi" },
  { name: "Costa Coffee", address: "Strand, London WC2R 1AP", position: { latitude: 51.5108, longitude: -0.12 }, keywords: ["coffee", "cafe", "café", "costa"], kind: "poi" },
  { name: "Starbucks", address: "Oxford Street, London W1C 1DX", position: { latitude: 51.5152, longitude: -0.141 }, keywords: ["coffee", "cafe", "café", "starbucks"], kind: "poi" },
  { name: "Caffè Nero", address: "Briggate, Leeds LS1 6HD", position: { latitude: 53.7975, longitude: -1.543 }, keywords: ["coffee", "cafe", "café", "nero"], kind: "poi" },
  { name: "NCP Car Park Soho", address: "Brewer Street, London W1F 0RG", position: { latitude: 51.5117, longitude: -0.1362 }, keywords: ["parking", "car park", "ncp"], kind: "poi" },
  { name: "Q-Park Leicester Square", address: "Whitcomb Street, London WC2H 7DT", position: { latitude: 51.5099, longitude: -0.13 }, keywords: ["parking", "car park", "qpark"], kind: "poi" },
  { name: "NCP The Light Leeds", address: "The Headrow, Leeds LS1 8TL", position: { latitude: 53.7999, longitude: -1.546 }, keywords: ["parking", "car park", "ncp"], kind: "poi" },
  { name: "Premier Inn London County Hall", address: "Belvedere Road, London SE1 7PB", position: { latitude: 51.5016, longitude: -0.1187 }, keywords: ["hotels", "hotel", "premier inn", "stay"], kind: "poi" },
  { name: "Travelodge London Central City Road", address: "1-23 City Road, London EC1Y 1AG", position: { latitude: 51.5257, longitude: -0.0886 }, keywords: ["hotels", "hotel", "travelodge", "stay"], kind: "poi" },
  { name: "The Queens Hotel", address: "City Square, Leeds LS1 1PJ", position: { latitude: 53.7955, longitude: -1.5478 }, keywords: ["hotels", "hotel", "queens", "stay"], kind: "poi" },
  { name: "Barclays ATM", address: "Piccadilly, London W1J 0DA", position: { latitude: 51.5085, longitude: -0.135 }, keywords: ["atm", "cash", "cash machine", "barclays"], kind: "poi" },
  { name: "HSBC ATM", address: "Oxford Street, London W1D 2HS", position: { latitude: 51.5148, longitude: -0.144 }, keywords: ["atm", "cash", "cash machine", "hsbc"], kind: "poi" },
  { name: "NatWest ATM", address: "Park Row, Leeds LS1 5NX", position: { latitude: 53.7985, longitude: -1.547 }, keywords: ["atm", "cash", "cash machine", "natwest"], kind: "poi" },
];

/**
 * Ranks a fixture entry against a lower-cased query for `search()`. Lower is
 * better; `null` means "no match, exclude". Tiers, best to worst:
 *   0 — exact name match ("manchester" === "manchester")
 *   1 — name starts with the query ("manchester piccadilly" for "manchester")
 *   2 — kind "place" (city/town/area) whose name contains the query
 *   3 — kind "poi" whose name contains the query
 *   4 — address or keyword match (no name hit)
 * This is what makes "Manchester" surface the city itself before Manchester
 * Piccadilly / Old Trafford / Etihad Stadium, while still letting landmark
 * searches (e.g. "stadium", "museum") work via the address/keyword tier.
 */
function matchRank(
  place: { name: string; address: string; keywords: string[]; kind: "place" | "poi" },
  q: string
): number | null {
  const name = place.name.toLowerCase();
  if (name === q) return 0;
  if (name.startsWith(q)) return 1;
  if (name.includes(q)) return place.kind === "place" ? 2 : 3;
  if (place.address.toLowerCase().includes(q) || place.keywords.some((k) => k.includes(q))) return 4;
  return null;
}

function bridgeError(code: BridgeErrorCode, message: string): BridgeError {
  return { code, message };
}

function delay(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
}

/** Distance-to-manoeuvre threshold within which lane guidance appears —
 * mirrors real SDKs, which surface lanes only once the turn is imminent. */
const LANE_GUIDANCE_DISTANCE_METERS = 150;

/** Maps a turn-shaped manoeuvre to the lane direction that stays "active"
 * for it; null for manoeuvre types lane guidance doesn't apply to (depart,
 * arrive, merges, forks, roundabouts, plain straight-on). */
function activeLaneDirectionFor(type: Maneuver["type"]): LaneInfo["directions"][number] | null {
  switch (type) {
    case "turn-left":
    case "sharp-left":
      return "left";
    case "slight-left":
      return "slight-left";
    case "turn-right":
    case "sharp-right":
      return "right";
    case "slight-right":
      return "slight-right";
    case "uturn":
      return "uturn";
    default:
      return null;
  }
}

/** Builds a plausible 3–4 lane set for a turn manoeuvre, the matching
 * lane(s) marked active, the rest through-only. Deterministic — driven
 * entirely by manoeuvre type, no randomness or wall-clock reads. */
function buildLanes(type: Maneuver["type"]): LaneInfo[] | undefined {
  const active = activeLaneDirectionFor(type);
  if (!active) return undefined;

  const through = (): LaneInfo => ({ directions: ["through"], active: false });
  const activeLane: LaneInfo = { directions: [active], active: true };

  if (active === "uturn") {
    return [activeLane, through(), through(), through()];
  }
  if (active === "left" || active === "slight-left") {
    return [activeLane, through(), through()];
  }
  return [through(), through(), activeLane];
}

function toBanner(
  maneuver: Maneuver,
  next: Maneuver | undefined,
  distanceToManeuverMeters?: number
): BannerInstruction {
  const lanes =
    typeof distanceToManeuverMeters === "number" && distanceToManeuverMeters <= LANE_GUIDANCE_DISTANCE_METERS
      ? buildLanes(maneuver.type)
      : undefined;
  return {
    primary: maneuver.instruction,
    maneuverType: maneuver.type,
    roundaboutExit: maneuver.roundaboutExit,
    thenPrimary: next?.instruction,
    thenManeuverType: next?.type,
    ...(lanes ? { lanes } : {}),
  };
}

/** Classifies a signed turn angle (degrees) into a maneuver type + instruction text. */
function classifyTurn(turnDegrees: number): { type: Maneuver["type"]; label: string } {
  const abs = Math.abs(turnDegrees);
  if (abs < 12) return { type: "straight", label: "Continue straight ahead" };
  if (abs > 150) return { type: "uturn", label: "Make a U-turn" };
  const side = turnDegrees < 0 ? "left" : "right";
  if (abs > 100) return { type: `sharp-${side}` as Maneuver["type"], label: `Make a sharp ${side} turn` };
  if (abs > 45) return { type: `turn-${side}` as Maneuver["type"], label: `Turn ${side}` };
  return { type: `slight-${side}` as Maneuver["type"], label: `Bear ${side}` };
}

function buildSyntheticRoute(request: RouteRequest, index: number): Route {
  const { origin, destination } = request;
  const straightDistance = Math.max(1, distanceMeters(origin, destination));
  const straightBearing = bearingDegrees(origin, destination);

  // Offset alternates side/magnitude per route index so alternatives look
  // visibly distinct from the primary, and grow a little longer than it.
  const side = index % 2 === 0 ? 1 : -1;
  const offsetFraction = 0.08 + index * 0.05;
  const offsetMeters = Math.min(400, Math.max(60, straightDistance * offsetFraction));
  const doglegBearing = straightBearing + side * 90;
  const doglegAnchor = destinationPoint(origin, straightBearing, straightDistance * 0.42);
  const dogleg = destinationPoint(doglegAnchor, doglegBearing, offsetMeters);

  const stepsPerLeg = 12;
  const leg1 = densifySegment(origin, dogleg, stepsPerLeg);
  const leg2 = densifySegment(dogleg, destination, stepsPerLeg);
  const geometry: LatLng[] = [...leg1, ...leg2.slice(1)];

  const bearing1 = bearingDegrees(origin, dogleg);
  const bearing2 = bearingDegrees(dogleg, destination);
  const turn = classifyTurn(signedTurnDegrees(bearing1, bearing2));

  const leg1Distance = pathLengthMeters(leg1);
  const leg2Distance = pathLengthMeters(leg2);

  const maneuvers: Maneuver[] = [
    {
      type: "depart",
      instruction: "Head out on the route",
      distanceMeters: leg1Distance,
      position: origin,
    },
    {
      type: turn.type,
      instruction: `${turn.label} to continue`,
      distanceMeters: leg2Distance,
      position: dogleg,
    },
    {
      type: "arrive",
      instruction: "You have arrived at your destination",
      distanceMeters: 0,
      position: destination,
    },
  ];

  const totalDistance = pathLengthMeters(geometry);
  const speed = PROFILE_CRUISE_SPEED_MPS[request.profile];

  return {
    index,
    geometry,
    distanceMeters: totalDistance,
    durationSeconds: totalDistance / speed,
    maneuvers,
  };
}

export class MapmapNavMock extends SimpleEventEmitter<MapmapNavEvents> implements MapmapNavModule {
  private initialised = false;
  private readonly installedTerritories = new Map<string, TerritoryInfo>();
  private lastRouteResult: RouteResult | null = null;
  private lastRouteProfile: RouteProfile = "car";
  private guidanceSession: GuidanceSession | null = null;
  private voice: VoiceStateEvent = { muted: false, volume: 1, speaking: false };

  constructor(private readonly options: MapmapNavMockOptions = {}) {
    super();
  }

  private ensureInitialised(): void {
    if (!this.initialised) {
      throw bridgeError("E_NOT_INITIALISED", "MapmapNavMock.init() has not resolved yet.");
    }
  }

  private activeTerritory(): TerritoryInfo | undefined {
    return Array.from(this.installedTerritories.values()).find((t) => t.active);
  }

  async init(_apiKey: string, _gatewayBaseUrl?: string): Promise<void> {
    await delay(0);
    this.initialised = true;
  }

  async listAvailableTerritories(): Promise<TerritorySummary[]> {
    this.ensureInitialised();
    return TERRITORY_CATALOG.map((t) => ({ ...t }));
  }

  async listInstalledTerritories(): Promise<TerritoryInfo[]> {
    this.ensureInitialised();
    return Array.from(this.installedTerritories.values()).map((t) => ({ ...t }));
  }

  async installTerritory(territoryId: string): Promise<void> {
    this.ensureInitialised();
    const catalogEntry = TERRITORY_CATALOG.find((t) => t.id === territoryId);
    if (!catalogEntry) {
      throw bridgeError("E_TERRITORY_NOT_FOUND", `Unknown territory '${territoryId}'.`);
    }

    const totalMs = this.options.territoryInstallDurationMs ?? 3000;
    const bytesTotal = catalogEntry.sizeBytes;
    const downloadTicks = 6;

    const emitProgress = (phase: TerritoryPhase, bytesDone: number) =>
      this.emit("territoryProgress", { territoryId, phase, bytesDone, bytesTotal });

    for (let i = 1; i <= downloadTicks; i++) {
      await delay((totalMs * 0.7) / downloadTicks);
      emitProgress("downloading", Math.round((bytesTotal * i) / downloadTicks));
    }

    await delay(totalMs * 0.15);
    emitProgress("verifying", bytesTotal);

    await delay(totalMs * 0.1);
    emitProgress("activating", bytesTotal);

    await delay(totalMs * 0.05);
    emitProgress("done", bytesTotal);

    this.installedTerritories.set(territoryId, {
      id: catalogEntry.id,
      name: catalogEntry.name,
      version: catalogEntry.version,
      sizeOnDiskBytes: catalogEntry.sizeBytes,
      active: true,
    });
  }

  async removeTerritory(territoryId: string): Promise<void> {
    this.ensureInitialised();
    if (!this.installedTerritories.has(territoryId)) {
      throw bridgeError("E_TERRITORY_NOT_FOUND", `Territory '${territoryId}' is not installed.`);
    }
    this.installedTerritories.delete(territoryId);
  }

  async getTerritoryStyle(territoryId: string, theme: "light" | "dark"): Promise<string> {
    this.ensureInitialised();
    if (!this.installedTerritories.has(territoryId)) {
      throw bridgeError("E_TERRITORY_NOT_FOUND", `Territory '${territoryId}' is not installed.`);
    }
    const style = {
      version: 8,
      name: `mock-${territoryId}-${theme}`,
      sources: {},
      layers: [
        {
          id: "background",
          type: "background",
          paint: { "background-color": theme === "dark" ? "#1C1B1F" : "#FFFBFE" },
        },
      ],
    };
    return JSON.stringify(style);
  }

  async search(query: string, near?: LatLng, limit = 10): Promise<SearchResult[]> {
    this.ensureInitialised();
    if (!this.activeTerritory()) {
      throw bridgeError("E_NO_TERRITORY", "No active territory to search against.");
    }
    const q = query.trim().toLowerCase();
    if (q.length === 0) return [];
    const anchor = near ?? { latitude: 51.5074, longitude: -0.1278 };

    const scored = LONDON_PLACES.map((p) => ({ place: p, rank: matchRank(p, q) }))
      .filter((entry): entry is { place: (typeof LONDON_PLACES)[number]; rank: number } => entry.rank !== null)
      .map((entry) => ({
        name: entry.place.name,
        address: entry.place.address,
        position: entry.place.position,
        distanceMeters: distanceMeters(anchor, entry.place.position),
        rank: entry.rank,
      }))
      // Lower rank number = better match. Ties broken by distance, so within
      // a rank tier (e.g. several POI keyword matches) the nearest wins —
      // exactly like a real geocoder.
      .sort((a, b) => a.rank - b.rank || (a.distanceMeters ?? 0) - (b.distanceMeters ?? 0))
      .map(({ name, address, position, distanceMeters: d }) => ({ name, address, position, distanceMeters: d }));

    return scored.slice(0, Math.max(0, Math.min(limit, 20)));
  }

  async computeRoute(request: RouteRequest): Promise<RouteResult> {
    this.ensureInitialised();
    if (!this.activeTerritory()) {
      throw bridgeError("E_NO_TERRITORY", "No active territory to route against.");
    }
    if (distanceMeters(request.origin, request.destination) < 1) {
      throw bridgeError("E_NO_ROUTE", "Origin and destination are effectively the same point.");
    }

    if (this.options.routeProvider) {
      const provided = await this.options.routeProvider(request).catch(() => null);
      if (provided && provided.routes.length > 0) {
        // Re-index defensively and remember for guidance replay.
        const routes = provided.routes.map((r, i) => ({ ...r, index: i }));
        this.lastRouteResult = { routes };
        this.lastRouteProfile = request.profile;
        return { routes: routes.map((r) => ({ ...r, geometry: [...r.geometry], maneuvers: [...r.maneuvers] })) };
      }
    }

    const alternatives = Math.max(0, Math.min(3, request.alternatives ?? 2));
    const routes: Route[] = Array.from({ length: alternatives + 1 }, (_, i) => buildSyntheticRoute(request, i));

    this.lastRouteResult = { routes };
    this.lastRouteProfile = request.profile;
    return { routes: routes.map((r) => ({ ...r, geometry: [...r.geometry], maneuvers: [...r.maneuvers] })) };
  }

  async startGuidance(options: GuidanceOptions): Promise<void> {
    this.ensureInitialised();
    if (this.guidanceSession?.active) {
      throw bridgeError("E_GUIDANCE_ACTIVE", "A guidance session is already running; call stopGuidance() first.");
    }
    const route = this.lastRouteResult?.routes[options.routeIndex];
    if (!route) {
      throw bridgeError("E_NO_ROUTE", `No computed route at index ${options.routeIndex}; call computeRoute() first.`);
    }

    const session: GuidanceSession = {
      active: true,
      routeIndex: options.routeIndex,
      route,
      profile: this.lastRouteProfile,
      options,
    };
    this.guidanceSession = session;

    // Fire-and-forget: resolves once the session has *started*; guidance
    // itself streams via `guidance` events, matching the real SDK contract.
    void this.runGuidanceLoop(session);
    await delay(0);
  }

  async stopGuidance(): Promise<void> {
    this.ensureInitialised();
    if (!this.guidanceSession?.active) {
      throw bridgeError("E_NO_GUIDANCE", "No guidance session is running.");
    }
    this.guidanceSession.active = false;
    this.guidanceSession = null;
  }

  async setVoiceMuted(muted: boolean): Promise<void> {
    this.ensureInitialised();
    this.voice = { ...this.voice, muted };
    this.emit("voiceState", { ...this.voice });
  }

  async setVoiceVolume(volume: number): Promise<void> {
    this.ensureInitialised();
    this.voice = { ...this.voice, volume: Math.max(0, Math.min(1, volume)) };
    this.emit("voiceState", { ...this.voice });
  }

  private announceSpeaking(): void {
    if (this.voice.muted) return;
    this.voice = { ...this.voice, speaking: true };
    this.emit("voiceState", { ...this.voice });
    void delay(Math.min(2000, (this.options.tickIntervalMs ?? 1000) * 0.6)).then(() => {
      this.voice = { ...this.voice, speaking: false };
      this.emit("voiceState", { ...this.voice });
    });
  }

  private async runGuidanceLoop(session: GuidanceSession): Promise<void> {
    const tickMs = this.options.tickIntervalMs ?? 1000;
    const speedMultiplier =
      session.options.source.kind === "replay" ? session.options.source.speedMultiplier ?? 1 : 1;
    const cruiseSpeed = PROFILE_CRUISE_SPEED_MPS[session.profile];
    const distancePerTick = cruiseSpeed * (tickMs / 1000) * speedMultiplier;
    const totalDistance = pathLengthMeters(session.route.geometry);
    const maneuverThresholds = cumulativeDistancesAt(
      session.route.geometry,
      session.route.maneuvers.map((m) => m.position)
    );

    let travelled = 0;
    let maneuverPointer = 0;
    let lastAnnouncedIndex = -1;
    let reroutedOnce = false;
    const simulateReroute = this.options.simulateReroute ?? true;

    while (session.active) {
      await delay(tickMs);
      if (!session.active) return;

      travelled = Math.min(totalDistance, travelled + distancePerTick);
      const { point, bearing } = pointAtDistance(session.route.geometry, travelled);
      const arrived = travelled >= totalDistance;

      while (
        maneuverPointer < maneuverThresholds.length - 1 &&
        travelled >= maneuverThresholds[maneuverPointer]
      ) {
        maneuverPointer++;
      }

      const distanceToManeuverMeters = Math.max(0, maneuverThresholds[maneuverPointer] - travelled);
      const remainingDistanceMeters = Math.max(0, totalDistance - travelled);
      const remainingDurationSeconds = cruiseSpeed > 0 ? remainingDistanceMeters / cruiseSpeed : 0;
      const etaEpochMs = Date.now() + remainingDurationSeconds * 1000;

      // One illustrative reroute blip, once the first real maneuver's
      // banner has already been announced at least one tick, to exercise
      // the "rerouting" branch of the state machine without swallowing the
      // very first navigating tick.
      if (
        simulateReroute &&
        !reroutedOnce &&
        lastAnnouncedIndex === 1 &&
        maneuverPointer === 1 &&
        session.route.maneuvers.length > 2
      ) {
        reroutedOnce = true;
        this.emit("guidance", {
          state: "rerouting",
          location: { latitude: point.latitude, longitude: point.longitude, bearingDegrees: bearing, speedMps: 0 },
          distanceToManeuverMeters,
          remainingDistanceMeters,
          remainingDurationSeconds,
          etaEpochMs,
          isOffRoute: true,
        } satisfies GuidanceEvent);
        continue;
      }

      const bannerChanged = maneuverPointer !== lastAnnouncedIndex;
      if (bannerChanged) {
        lastAnnouncedIndex = maneuverPointer;
        this.announceSpeaking();
      }

      const currentManeuver = session.route.maneuvers[maneuverPointer];
      const nextManeuver = session.route.maneuvers[maneuverPointer + 1];

      const event: GuidanceEvent = {
        state: arrived ? "arrived" : "navigating",
        location: {
          latitude: point.latitude,
          longitude: point.longitude,
          bearingDegrees: bearing,
          speedMps: arrived ? 0 : cruiseSpeed * speedMultiplier,
        },
        banner: toBanner(currentManeuver, nextManeuver, distanceToManeuverMeters),
        distanceToManeuverMeters,
        remainingDistanceMeters,
        remainingDurationSeconds,
        etaEpochMs,
        isOffRoute: false,
      };
      this.emit("guidance", event);

      if (arrived) {
        session.active = false;
        if (this.guidanceSession === session) {
          this.guidanceSession = null;
        }
        return;
      }
    }
  }
}

export function createMapmapNavMock(options?: MapmapNavMockOptions): MapmapNavMock {
  return new MapmapNavMock(options);
}
