const TWO_PI = Math.PI * 2;
const WGS84_SEMI_MAJOR_METERS = 6_378_137;
const WGS84_FLATTENING = 1 / 298.257_223_563;
const WGS84_SEMI_MINOR_METERS = WGS84_SEMI_MAJOR_METERS * (1 - WGS84_FLATTENING);

// This survey mode is deliberately regional. Radially projecting an
// Archimedean spiral onto WGS84 stays within a negligible speed/tangent error
// at this radius; a future continental mode needs an ellipsoid-arc
// reparameterization instead of silently pretending the flat formula is exact.
export const MAX_SPIRAL_RADIUS_METERS = 250_000;

export type GeodeticRadians = {
  longitude: number;
  latitude: number;
};

export type SpiralSurfaceFrame = GeodeticRadians & {
  angle: number;
  radiusMeters: number;
  radialBearing: number;
  tangentHeading: number;
};

export function normalizeRadians(value: number) {
  const normalized = ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI;
  return normalized === -Math.PI ? Math.PI : normalized;
}

export function spiralRadiusAtAngle(angle: number, pitchMetersPerTurn: number) {
  const safeAngle = Math.max(0, finiteNumber(angle, "spiral_angle_invalid"));
  const pitch = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid");
  return (pitch / TWO_PI) * safeAngle;
}

export function spiralArcLengthAtAngle(angle: number, pitchMetersPerTurn: number) {
  const safeAngle = Math.max(0, finiteNumber(angle, "spiral_angle_invalid"));
  const b = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid") / TWO_PI;
  return (b / 2) * (
    safeAngle * Math.sqrt(1 + safeAngle * safeAngle)
    + Math.asinh(safeAngle)
  );
}

/**
 * Inverts the Archimedean spiral arc-length function. This keeps the camera's
 * configured surface speed constant instead of accelerating as the radius
 * grows. Newton is monotone here; the bounded fallback protects the first
 * samples near the origin from numerical overshoot.
 */
export function spiralAngleAtArcLength(surfaceDistanceMeters: number, pitchMetersPerTurn: number) {
  const distance = Math.max(0, finiteNumber(surfaceDistanceMeters, "spiral_distance_invalid"));
  const pitch = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid");
  if (distance === 0) return 0;
  const b = pitch / TWO_PI;
  let angle = distance <= b ? distance / b : Math.sqrt((2 * distance) / b);
  for (let iteration = 0; iteration < 10; iteration += 1) {
    const error = spiralArcLengthAtAngle(angle, pitch) - distance;
    if (Math.abs(error) <= Math.max(1e-7, distance * 1e-12)) break;
    const derivative = b * Math.sqrt(1 + angle * angle);
    const next = angle - error / derivative;
    angle = Number.isFinite(next) && next >= 0 ? next : angle / 2;
  }
  return angle;
}

/**
 * Vincenty's WGS84 direct solution: move from a geodetic point along an
 * initial bearing by an exact ellipsoid surface distance. Longitude/latitude
 * are radians. The animation therefore remains stable at datelines and high
 * latitudes and never treats degrees as a flat Cartesian plane.
 */
export function directGeodesicDestination(
  origin: GeodeticRadians,
  initialBearing: number,
  distanceMeters: number,
): GeodeticRadians {
  const latitude1 = finiteNumber(origin.latitude, "spiral_origin_invalid");
  const longitude1 = finiteNumber(origin.longitude, "spiral_origin_invalid");
  const bearing = finiteNumber(initialBearing, "spiral_bearing_invalid");
  const distance = Math.max(0, finiteNumber(distanceMeters, "spiral_distance_invalid"));
  if (Math.abs(latitude1) > Math.PI / 2 + 1e-12) throw new Error("spiral_origin_invalid");
  if (distance === 0) return { longitude: normalizeRadians(longitude1), latitude: latitude1 };

  const sinBearing = Math.sin(bearing);
  const cosBearing = Math.cos(bearing);
  const tanReducedLatitude1 = (1 - WGS84_FLATTENING) * Math.tan(latitude1);
  const cosReducedLatitude1 = 1 / Math.sqrt(1 + tanReducedLatitude1 * tanReducedLatitude1);
  const sinReducedLatitude1 = tanReducedLatitude1 * cosReducedLatitude1;
  const sigma1 = Math.atan2(tanReducedLatitude1, cosBearing);
  const sinAlpha = cosReducedLatitude1 * sinBearing;
  const cosSquaredAlpha = 1 - sinAlpha * sinAlpha;
  const uSquared = cosSquaredAlpha
    * (WGS84_SEMI_MAJOR_METERS ** 2 - WGS84_SEMI_MINOR_METERS ** 2)
    / (WGS84_SEMI_MINOR_METERS ** 2);
  const coefficientA = 1 + (uSquared / 16_384)
    * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared)));
  const coefficientB = (uSquared / 1024)
    * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared)));

  let sigma = distance / (WGS84_SEMI_MINOR_METERS * coefficientA);
  let previousSigma = Number.POSITIVE_INFINITY;
  let sinSigma = 0;
  let cosSigma = 1;
  let cosTwoSigmaMiddle = 0;
  for (let iteration = 0; iteration < 24 && Math.abs(sigma - previousSigma) > 1e-12; iteration += 1) {
    cosTwoSigmaMiddle = Math.cos(2 * sigma1 + sigma);
    sinSigma = Math.sin(sigma);
    cosSigma = Math.cos(sigma);
    const deltaSigma = coefficientB * sinSigma * (
      cosTwoSigmaMiddle
      + (coefficientB / 4) * (
        cosSigma * (-1 + 2 * cosTwoSigmaMiddle ** 2)
        - (coefficientB / 6) * cosTwoSigmaMiddle
          * (-3 + 4 * sinSigma ** 2)
          * (-3 + 4 * cosTwoSigmaMiddle ** 2)
      )
    );
    previousSigma = sigma;
    sigma = distance / (WGS84_SEMI_MINOR_METERS * coefficientA) + deltaSigma;
  }

  sinSigma = Math.sin(sigma);
  cosSigma = Math.cos(sigma);
  cosTwoSigmaMiddle = Math.cos(2 * sigma1 + sigma);
  const temporary = sinReducedLatitude1 * sinSigma
    - cosReducedLatitude1 * cosSigma * cosBearing;
  const latitude2 = Math.atan2(
    sinReducedLatitude1 * cosSigma + cosReducedLatitude1 * sinSigma * cosBearing,
    (1 - WGS84_FLATTENING) * Math.sqrt(sinAlpha ** 2 + temporary ** 2),
  );
  const lambda = Math.atan2(
    sinSigma * sinBearing,
    cosReducedLatitude1 * cosSigma - sinReducedLatitude1 * sinSigma * cosBearing,
  );
  const coefficientC = (WGS84_FLATTENING / 16) * cosSquaredAlpha
    * (4 + WGS84_FLATTENING * (4 - 3 * cosSquaredAlpha));
  const longitudeDelta = lambda - (1 - coefficientC) * WGS84_FLATTENING * sinAlpha * (
    sigma + coefficientC * sinSigma * (
      cosTwoSigmaMiddle + coefficientC * cosSigma * (-1 + 2 * cosTwoSigmaMiddle ** 2)
    )
  );
  return {
    longitude: normalizeRadians(longitude1 + longitudeDelta),
    latitude: latitude2,
  };
}

export function spiralSurfaceFrame(
  origin: GeodeticRadians,
  initialHeading: number,
  surfaceDistanceMeters: number,
  pitchMetersPerTurn: number,
): SpiralSurfaceFrame {
  const angle = spiralAngleAtArcLength(surfaceDistanceMeters, pitchMetersPerTurn);
  const radiusMeters = spiralRadiusAtAngle(angle, pitchMetersPerTurn);
  if (radiusMeters > MAX_SPIRAL_RADIUS_METERS) throw new Error("spiral_extent_limit");
  const radialBearing = normalizeRadians(initialHeading + angle);
  const destination = directGeodesicDestination(origin, radialBearing, radiusMeters);
  // In polar coordinates dr/dθ=b and r=bθ. The path tangent is therefore
  // rotated atan2(r, dr/dθ)=atan(θ) from the outward radial direction.
  const tangentHeading = normalizeRadians(radialBearing + Math.atan(angle));
  return {
    ...destination,
    angle,
    radiusMeters,
    radialBearing,
    tangentHeading,
  };
}

function finiteNumber(value: number, errorCode: string) {
  if (!Number.isFinite(value)) throw new Error(errorCode);
  return value;
}

function positiveNumber(value: number, errorCode: string) {
  const finite = finiteNumber(value, errorCode);
  if (finite <= 0) throw new Error(errorCode);
  return finite;
}
