/**
 * Route-progress engine (pure) — the shared maths behind BOTH real-GPS
 * guidance and the simulated demo drive.
 *
 * The core idea (how every production nav SDK works): don't chase a raw GPS
 * lat/lng. Instead **map-match** the fix onto the route polyline and track a
 * single scalar — distance travelled along the route (arc length). Everything
 * the guidance UI needs (puck position, bearing, distance-to-turn, remaining
 * distance/time, the active banner) is then a pure function of that scalar and
 * the route. That's what keeps the puck glued to the road and the motion
 * smooth, and it's identical whether the scalar comes from a real GPS fix or a
 * simulated clock.
 */
import type {
  BannerInstruction,
  GuidanceEvent,
  LaneDirection,
  LaneInfo,
  LatLng,
  Maneuver,
  ManeuverType,
  Route,
} from "../../../modules/mapmap-react-native/src/MapmapNav.types";
import {
  bearingDegrees,
  cumulativeDistancesAt,
  distanceMeters,
  pathLengthMeters,
  pointAtDistance,
} from "../../../modules/mapmap-react-native/src/geo";

const METRES_PER_DEG_LAT = 111_320;

export type RouteSnap = {
  /** Distance along the route (metres) of the point nearest `position`. */
  distanceAlong: number;
  /** Perpendicular distance (metres) from `position` to the route — the
   * off-route signal. */
  lateralMetres: number;
};

/**
 * Map-match: project `position` onto the route polyline, returning the
 * arc-length of the nearest point and how far off the line it is. Uses a local
 * equirectangular projection per segment (fine at street scale).
 */
export function snapToRoute(position: LatLng, geometry: readonly LatLng[]): RouteSnap {
  if (geometry.length === 0) return { distanceAlong: 0, lateralMetres: 0 };
  if (geometry.length === 1) {
    return { distanceAlong: 0, lateralMetres: distanceMeters(position, geometry[0]) };
  }

  let best: RouteSnap = { distanceAlong: 0, lateralMetres: Infinity };
  let accumulated = 0;

  for (let i = 0; i < geometry.length - 1; i += 1) {
    const a = geometry[i];
    const b = geometry[i + 1];
    const segmentLength = distanceMeters(a, b);

    // Local metres relative to `a`.
    const cosLat = Math.cos((a.latitude * Math.PI) / 180);
    const ax = 0;
    const ay = 0;
    const bx = (b.longitude - a.longitude) * METRES_PER_DEG_LAT * cosLat;
    const by = (b.latitude - a.latitude) * METRES_PER_DEG_LAT;
    const px = (position.longitude - a.longitude) * METRES_PER_DEG_LAT * cosLat;
    const py = (position.latitude - a.latitude) * METRES_PER_DEG_LAT;

    const dx = bx - ax;
    const dy = by - ay;
    const lengthSq = dx * dx + dy * dy;
    const t = lengthSq > 0 ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSq)) : 0;

    const projX = ax + t * dx;
    const projY = ay + t * dy;
    const lateral = Math.hypot(px - projX, py - projY);

    if (lateral < best.lateralMetres) {
      best = { distanceAlong: accumulated + t * segmentLength, lateralMetres: lateral };
    }
    accumulated += segmentLength;
  }

  return best;
}

/** BannerInstruction from a manoeuvre + its successor. Lanes are attached by
 * the caller (progressAlongRoute) once the turn is imminent. */
export function buildBanner(maneuver: Maneuver, next: Maneuver | undefined): BannerInstruction {
  return {
    primary: maneuver.instruction,
    maneuverType: maneuver.type,
    ...(typeof maneuver.roundaboutExit === "number"
      ? { roundaboutExit: maneuver.roundaboutExit }
      : {}),
    ...(next ? { thenPrimary: next.instruction, thenManeuverType: next.type } : {}),
  };
}

/** Lane guidance appears once the turn is this close (metres) — mirrors real
 * SDKs, which surface lanes only when the manoeuvre is imminent. */
const LANE_GUIDANCE_DISTANCE_M = 160;

/** Active lane direction — ONLY for genuinely multi-lane manoeuvres (motorway
 * forks/ramps/merges), so lane guidance doesn't pop up on every ordinary turn
 * (residential turns are single-lane). Null for everything else. Without real
 * lane data from the SDK, manoeuvre type is the best "this road has lanes"
 * signal we have. */
function activeLaneDirectionFor(type: ManeuverType): LaneDirection | null {
  switch (type) {
    case "fork-left":
    case "off-ramp":
      return "slight-left";
    case "fork-right":
    case "on-ramp":
    case "merge":
      return "slight-right";
    default:
      return null;
  }
}

/** A plausible multi-lane set (the active side lane + a couple of through
 * lanes) for a motorway manoeuvre. Deterministic (driven by manoeuvre type). */
function buildLanes(type: ManeuverType): LaneInfo[] | undefined {
  const active = activeLaneDirectionFor(type);
  if (!active) return undefined;
  const through = (): LaneInfo => ({ directions: ["through"], active: false });
  const activeLane: LaneInfo = { directions: [active], active: true };
  return active === "slight-left"
    ? [activeLane, through(), through()]
    : [through(), through(), activeLane];
}

export type RouteProgress = {
  /** Puck position, snapped to the route. */
  location: LatLng;
  /** Heading along the route at that point (degrees). */
  bearingDegrees: number;
  distanceToManeuverMetres: number;
  remainingDistanceMetres: number;
  remainingDurationSeconds: number;
  banner: BannerInstruction;
  /** Name of the road currently being driven (from the route's leg data),
   * or undefined when the current leg is unnamed. */
  currentRoadName?: string;
  arrived: boolean;
};

const ARRIVAL_THRESHOLD_M = 8;

/**
 * The route's expensive-to-compute, PER-TICK-CONSTANT quantities: total length
 * and each manoeuvre's arc-length along the route. Computing these once (in the
 * drive engine, per route) instead of on every guidance tick is essential — on
 * a long dense route `cumulativeDistancesAt` is O(geometry × manoeuvres) and
 * recomputing it a few times a second saturates the JS thread and freezes the
 * UI. Prepare once, then `progressAlongRoute` is cheap (one O(geometry) walk).
 */
export type PreparedRoute = {
  totalMetres: number;
  maneuverThresholds: number[];
};

export function prepareRoute(route: Route): PreparedRoute {
  return {
    totalMetres: pathLengthMeters(route.geometry),
    maneuverThresholds: cumulativeDistancesAt(
      route.geometry,
      route.maneuvers.map((m) => m.position)
    ),
  };
}

/** Fallback "keep going" banner for a route with no manoeuvre data. */
const CONTINUE_BANNER: BannerInstruction = {
  primary: "Continue on route",
  maneuverType: "straight",
};

/**
 * Everything the guidance UI needs at a given arc-length along the route.
 * `speedMps` (from the GPS fix, or the simulated cruise speed) drives the ETA;
 * it falls back to the route's own average when zero/stationary. `prepared`
 * carries the per-tick-constant maths (see PreparedRoute).
 */
export function progressAlongRoute(
  route: Route,
  prepared: PreparedRoute,
  distanceAlong: number,
  speedMps: number
): RouteProgress {
  const total = prepared.totalMetres;
  const clamped = Math.max(0, Math.min(distanceAlong, total));
  const { point, bearing } = pointAtDistance(route.geometry, clamped);

  const thresholds = prepared.maneuverThresholds;
  let pointer = 0;
  while (pointer < thresholds.length - 1 && clamped >= thresholds[pointer]) {
    pointer += 1;
  }

  const distanceToManeuver =
    thresholds.length > 0 ? Math.max(0, thresholds[pointer] - clamped) : 0;
  const remaining = Math.max(0, total - clamped);
  const averageSpeed =
    route.durationSeconds > 0 ? total / route.durationSeconds : 13.4; // ~30mph fallback
  const effectiveSpeed = speedMps > 0.5 ? speedMps : averageSpeed;
  const remainingDuration = effectiveSpeed > 0 ? remaining / effectiveSpeed : 0;
  const currentManeuver = route.maneuvers[pointer];
  const lanes =
    currentManeuver && distanceToManeuver <= LANE_GUIDANCE_DISTANCE_M
      ? buildLanes(currentManeuver.type)
      : undefined;

  // The road we're on now is the leg leading up to the next manoeuvre — the
  // manoeuvre that begins it (pointer - 1). This ties the "current road" to the
  // actual route segment, so it flips to the new road the instant we leave a
  // roundabout/junction rather than lingering on the old name.
  const currentLegIndex = pointer > 0 ? pointer - 1 : 0;
  const currentRoadName = route.maneuvers[currentLegIndex]?.roadName;

  return {
    location: point,
    bearingDegrees: bearing,
    distanceToManeuverMetres: distanceToManeuver,
    remainingDistanceMetres: remaining,
    remainingDurationSeconds: remainingDuration,
    banner: currentManeuver
      ? { ...buildBanner(currentManeuver, route.maneuvers[pointer + 1]), ...(lanes ? { lanes } : {}) }
      : CONTINUE_BANNER,
    ...(currentRoadName ? { currentRoadName } : {}),
    arrived: clamped >= total - ARRIVAL_THRESHOLD_M,
  };
}

/**
 * Assemble a full GuidanceEvent from a route + a snapped arc-length + the live
 * speed, plus the off-route state. `nowMs` is passed in (never read from the
 * clock here) so this stays pure and testable.
 */
export function guidanceEventFromProgress(
  route: Route,
  prepared: PreparedRoute,
  distanceAlong: number,
  speedMps: number,
  bearingOverrideDegrees: number | undefined,
  isOffRoute: boolean,
  rerouting: boolean,
  nowMs: number,
  rawPosition?: LatLng
): GuidanceEvent {
  const p = progressAlongRoute(route, prepared, distanceAlong, speedMps);
  const state: GuidanceEvent["state"] = p.arrived
    ? "arrived"
    : rerouting
      ? "rerouting"
      : "navigating";
  // When off-route, DON'T pin the puck to the planned line — show the driver's
  // real GPS position and heading, so the puck visibly leaves the route until a
  // reroute lands. On-route, keep the map-matched point (smooth, road-snapped).
  const useRaw = isOffRoute && rawPosition !== undefined;
  const location = useRaw ? (rawPosition as LatLng) : p.location;
  const bearing =
    typeof bearingOverrideDegrees === "number" ? bearingOverrideDegrees : p.bearingDegrees;
  return {
    state,
    location: {
      latitude: location.latitude,
      longitude: location.longitude,
      bearingDegrees: bearing,
      speedMps: Math.max(0, speedMps),
    },
    banner: p.banner,
    distanceToManeuverMeters: p.distanceToManeuverMetres,
    remainingDistanceMeters: p.remainingDistanceMetres,
    remainingDurationSeconds: p.remainingDurationSeconds,
    etaEpochMs: nowMs + p.remainingDurationSeconds * 1000,
    isOffRoute,
    ...(p.currentRoadName ? { currentRoadName: p.currentRoadName } : {}),
  };
}

export { bearingDegrees };
