/**
 * Where the sun actually is, computed rather than fetched.
 *
 * This is the NOAA Solar Calculator's algorithm, which is Jean Meeus,
 * *Astronomical Algorithms* (2nd ed., 1998) — chapter 22 for nutation and
 * obliquity, 25 for the sun's coordinates, 28 for the equation of time — at the
 * low-precision truncation NOAA uses. It is good to roughly 0.01°, a minute of
 * arc, for dates between about 1800 and 2100. That is a fifth of the sun's own
 * half-degree disc, so nothing a renderer does with it can see the error.
 * Sunrise and sunset land within a minute or so at temperate latitudes and
 * degrade towards the poles, where the sun crosses the horizon at a shallow
 * enough angle that a minute of arc is a long time.
 *
 * There is a perfectly good web service for this and we do not use it. The
 * whole engine has to work with no account, no key and no network, and a
 * time-of-day that silently stops moving when the wifi drops is worse than one
 * that was never real. Two hundred lines of trigonometry buys that outright.
 *
 * Nothing here imports three.js: this is arithmetic, and `atmosphere.ts` is
 * what turns it into light.
 */

const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;

const MS_PER_MINUTE = 60_000;
const MS_PER_DAY = 86_400_000;

/**
 * Zenith angle counted as sunrise. 90° puts the sun's centre on the horizon;
 * the extra 0.833° is its 16' semidiameter plus the 34' of refraction that
 * lifts the upper limb into view before it geometrically arrives.
 */
const SUNRISE_ZENITH = 90.833;

export interface SolarPosition {
  /** Degrees clockwise from true north: 90 is due east, 180 due south. */
  azimuth: number;
  /** Degrees above the horizon, with atmospheric refraction applied. */
  elevation: number;
  /** The sun's declination in degrees — its latitude on the celestial sphere. */
  declination: number;
  /** Apparent solar time minus mean solar time, in minutes. Roughly ±16. */
  equationOfTime: number;
}

/**
 * The standard twilight bands, plus the one photographers named.
 *
 * The boundaries are applied to the refraction-corrected elevation this module
 * returns, so `daylightPhase` and `sunTimes` agree about where the horizon is
 * to within a few seconds rather than the ~90 s they would disagree by if the
 * horizon sat at a flat zero.
 */
export type DaylightPhase = "night" | "astronomical" | "nautical" | "civil" | "golden" | "day";

// ---- The sun's coordinates ------------------------------------------------

interface SolarTerms {
  declination: number;
  equationOfTime: number;
}

/**
 * The Unix epoch is JD 2440587.5, and `Date` is already a count of
 * milliseconds, so the Gregorian calendar arithmetic of Meeus chapter 7 never
 * has to happen here.
 */
function julianDay(when: Date): number {
  return when.getTime() / MS_PER_DAY + 2_440_587.5;
}

/** Julian centuries since J2000.0, the argument every series below is in. */
function julianCentury(jd: number): number {
  return (jd - 2_451_545) / 36_525;
}

function solarTerms(t: number): SolarTerms {
  const meanLongitude = mod(280.46646 + t * (36_000.76983 + t * 0.0003032), 360);
  const meanAnomaly = 357.52911 + t * (35_999.05029 - 0.0001537 * t);
  const eccentricity = 0.016708634 - t * (0.000042037 + 0.0000001267 * t);

  // Equation of centre: the correction from the fictitious mean sun, which
  // moves uniformly, to the real one, which does not because the orbit is an
  // ellipse. Three sine terms are plenty at this precision.
  const centre =
    Math.sin(meanAnomaly * DEG) * (1.914602 - t * (0.004817 + 0.000014 * t)) +
    Math.sin(2 * meanAnomaly * DEG) * (0.019993 - 0.000101 * t) +
    Math.sin(3 * meanAnomaly * DEG) * 0.000289;
  const trueLongitude = meanLongitude + centre;

  // Nutation and aberration, both driven by the moon's ascending node, folded
  // into the one term Meeus gives for them together.
  const omega = 125.04 - 1934.136 * t;
  const apparentLongitude = trueLongitude - 0.00569 - 0.00478 * Math.sin(omega * DEG);

  const meanObliquity = 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60;
  const obliquity = meanObliquity + 0.00256 * Math.cos(omega * DEG);

  const declination =
    Math.asin(Math.sin(obliquity * DEG) * Math.sin(apparentLongitude * DEG)) * RAD;

  // Equation of time, Meeus 28.3. `y` is tan²(ε/2); the series is in the mean
  // longitude and anomaly, not the true ones, which is easy to get wrong.
  const y = Math.tan((obliquity / 2) * DEG) ** 2;
  const equationOfTime =
    4 *
    RAD *
    (y * Math.sin(2 * meanLongitude * DEG) -
      2 * eccentricity * Math.sin(meanAnomaly * DEG) +
      4 * eccentricity * y * Math.sin(meanAnomaly * DEG) * Math.cos(2 * meanLongitude * DEG) -
      0.5 * y * y * Math.sin(4 * meanLongitude * DEG) -
      1.25 * eccentricity ** 2 * Math.sin(2 * meanAnomaly * DEG));

  return { declination, equationOfTime };
}

/**
 * Atmospheric refraction in degrees, to be added to the geometric elevation.
 *
 * The atmosphere bends light down over the horizon, so a low sun appears
 * higher than it is — by more than its own diameter at the horizon itself,
 * which is why a sunset you can see has already happened. NOAA's piecewise fit
 * in arcseconds; it assumes ordinary sea-level pressure and temperature, and
 * the last branch is a fiction that keeps the curve continuous below the
 * horizon rather than a claim about anything observable.
 */
function refraction(elevation: number): number {
  if (elevation > 85) return 0;
  const te = Math.tan(elevation * DEG);
  let arcseconds: number;
  if (elevation > 5) {
    arcseconds = 58.1 / te - 0.07 / te ** 3 + 0.000086 / te ** 5;
  } else if (elevation > -0.575) {
    arcseconds =
      1735 +
      elevation * (-518.2 + elevation * (103.4 + elevation * (-12.79 + elevation * 0.711)));
  } else {
    arcseconds = -20.772 / te;
  }
  return arcseconds / 3600;
}

/**
 * Azimuth and elevation for an observer at `lat`/`lng`, in degrees, at an
 * instant. Longitude is positive east, which is the sign convention the rest
 * of the engine uses and the opposite of NOAA's own spreadsheet.
 */
export function solarPosition(lat: number, lng: number, when: Date): SolarPosition {
  const { declination, equationOfTime } = solarTerms(julianCentury(julianDay(when)));

  // Apparent solar time: minutes of UTC, carried east four minutes per degree
  // of longitude, then bent from mean to apparent by the equation of time.
  const utcMinutes = mod(when.getTime() / MS_PER_MINUTE, 1440);
  const trueSolarTime = mod(utcMinutes + equationOfTime + 4 * lng, 1440);
  const hourAngle = trueSolarTime / 4 - 180;

  const latRad = lat * DEG;
  const decRad = declination * DEG;
  const cosZenith = clamp(
    Math.sin(latRad) * Math.sin(decRad) +
      Math.cos(latRad) * Math.cos(decRad) * Math.cos(hourAngle * DEG),
    -1,
    1,
  );
  const zenith = Math.acos(cosZenith) * RAD;
  const geometric = 90 - zenith;

  const sinZenith = Math.sin(zenith * DEG);
  const cosLat = Math.cos(latRad);
  let azimuth: number;
  if (Math.abs(sinZenith) < 1e-9 || Math.abs(cosLat) < 1e-9) {
    // The sun within a hair of the zenith, or the observer standing on a pole.
    // Azimuth is genuinely undefined at both, and at the first it also does not
    // matter — the light is coming straight down. Fall back to the hour angle,
    // which is continuous and gets the meridian crossing right.
    azimuth = mod(hourAngle + 180, 360);
  } else {
    const cosAzimuth = clamp(
      (Math.sin(latRad) * cosZenith - Math.sin(decRad)) / (cosLat * sinZenith),
      -1,
      1,
    );
    const a = Math.acos(cosAzimuth) * RAD;
    // `acos` cannot tell morning from afternoon; the hour angle can.
    azimuth = hourAngle > 0 ? mod(a + 180, 360) : mod(540 - a, 360);
  }

  return {
    azimuth,
    elevation: geometric + refraction(geometric),
    declination,
    equationOfTime,
  };
}

// ---- Rise, set and noon ---------------------------------------------------

/**
 * Sunrise, sunset and solar noon for the local solar day containing `when`.
 *
 * `sunrise` and `sunset` are `null` through a polar summer or winter, when the
 * sun does not cross the horizon at all. That is a legitimate answer, not an
 * error, and a caller rendering Tromsø in January should get a long blue day
 * rather than an exception.
 */
export function sunTimes(
  lat: number,
  lng: number,
  when: Date,
): { sunrise: Date | null; sunset: Date | null; solarNoon: Date } {
  const dayStart = solarDayStart(lng, when);

  // Solar noon is local mean noon pulled back by the equation of time. Two
  // passes because the equation of time itself wants evaluating at the answer,
  // and it moves slowly enough that two is convergence.
  let solarNoonMs = dayStart + MS_PER_DAY / 2;
  for (let i = 0; i < 2; i++) {
    const terms = solarTerms(julianCentury(julianDay(new Date(solarNoonMs))));
    solarNoonMs = dayStart + MS_PER_DAY / 2 - terms.equationOfTime * MS_PER_MINUTE;
  }

  return {
    sunrise: refineEvent(lat, solarNoonMs, -1),
    sunset: refineEvent(lat, solarNoonMs, 1),
    solarNoon: new Date(solarNoonMs),
  };
}

/**
 * Local *mean solar* midnight, as a UTC timestamp — four minutes of day per
 * degree of longitude.
 *
 * Using the solar day rather than the UTC calendar day is what makes a caller
 * in San Francisco at 23:00 local get tonight's sunset instead of tomorrow's,
 * and it needs no timezone database, which is just as well: we could not ship
 * one and still claim to work offline forever.
 */
function solarDayStart(lng: number, when: Date): number {
  const offset = lng * 4 * MS_PER_MINUTE;
  const local = when.getTime() + offset;
  return Math.floor(local / MS_PER_DAY) * MS_PER_DAY - offset;
}

/**
 * The hour angle at which the sun reaches `SUNRISE_ZENITH`, in degrees, or
 * `null` if it never does. The cosine leaving [-1, 1] is exactly the polar
 * day/night case; at the poles themselves the denominator also collapses, so
 * the finiteness check has to come first.
 */
function sunriseHourAngle(lat: number, declination: number): number | null {
  const latRad = lat * DEG;
  const decRad = declination * DEG;
  const c =
    Math.cos(SUNRISE_ZENITH * DEG) / (Math.cos(latRad) * Math.cos(decRad)) -
    Math.tan(latRad) * Math.tan(decRad);
  if (!Number.isFinite(c) || c > 1 || c < -1) return null;
  return Math.acos(c) * RAD;
}

/**
 * Walk an event in from solar noon. The declination is evaluated at the event's
 * own approximate time rather than at noon, which is worth several seconds at
 * the solstices and much more than that at high latitude.
 */
function refineEvent(lat: number, solarNoonMs: number, sign: 1 | -1): Date | null {
  let ms = solarNoonMs;
  for (let i = 0; i < 2; i++) {
    const { declination } = solarTerms(julianCentury(julianDay(new Date(ms))));
    const hourAngle = sunriseHourAngle(lat, declination);
    if (hourAngle === null) return null;
    ms = solarNoonMs + sign * hourAngle * 4 * MS_PER_MINUTE;
  }
  return new Date(ms);
}

// ---- Consumption ----------------------------------------------------------

/**
 * The elevation `solarPosition` reports at the instant `sunTimes` calls
 * sunrise: the sun's centre is 0.833° below the true horizon and refraction is
 * lifting it back by about 0.41° of that. Deriving it rather than writing
 * -0.42 down keeps the two functions agreeing if `SUNRISE_ZENITH` ever moves.
 */
const HORIZON = 90 - SUNRISE_ZENITH + refraction(90 - SUNRISE_ZENITH);

/**
 * Which band of light we are in, from an elevation in degrees.
 *
 * Astronomical, nautical and civil twilight are defined on the *geometric*
 * elevation, but refraction is under a tenth of a degree by 6° down, so
 * applying them to the refracted value costs nothing and saves the caller
 * carrying two elevations around.
 */
export function daylightPhase(elevation: number): DaylightPhase {
  if (elevation >= 6) return "day";
  if (elevation >= HORIZON) return "golden";
  if (elevation >= -6) return "civil";
  if (elevation >= -12) return "nautical";
  if (elevation >= -18) return "astronomical";
  return "night";
}

/**
 * A unit vector pointing *from the scene towards the sun*, in the engine's
 * axes: `x` east, `z` south, `y` up — the same convention `World.project`
 * establishes, where north is `-z`.
 *
 * This is the direction to put a light in, not the direction light travels;
 * negate it for the latter. A `THREE.DirectionalLight` wants
 * `light.position.copy(dir).multiplyScalar(distance)` with its target at the
 * origin, because three.js reads a directional light's direction off the vector
 * between the two.
 */
export function sunDirection(pos: SolarPosition): { x: number; y: number; z: number } {
  const el = pos.elevation * DEG;
  const az = pos.azimuth * DEG;
  const horizontal = Math.cos(el);
  return {
    x: horizontal * Math.sin(az),
    // Azimuth is measured from north and north is -z, so the northward
    // component is negated on the way in. A midday sun in the northern
    // hemisphere sits due south at azimuth 180, which lands on +z.
    z: -horizontal * Math.cos(az),
    y: Math.sin(el),
  };
}

// ---- Helpers --------------------------------------------------------------

/** `%` keeps the sign of the dividend, which is wrong for angles and clocks. */
function mod(x: number, n: number): number {
  return ((x % n) + n) % n;
}

function clamp(x: number, lo: number, hi: number): number {
  return x < lo ? lo : x > hi ? hi : x;
}

// ---- Sanity checks --------------------------------------------------------

/**
 * Values a reviewer can check against an almanac without running anything, or
 * against a reference implementation without trusting this one.
 *
 * San Francisco is 37.7749° N, 122.4194° W. Its noon sun is always due south,
 * because the latitude is north of the tropic, so the noon azimuth is 180.00
 * every day of the year. The noon elevation is 90° minus the latitude plus the
 * declination:
 *
 *   June solstice       90 - 37.7749 + 23.44 = 75.67°   (returned: 75.67)
 *   equinox             90 - 37.7749 +  0    = 52.23°   (returned: 52.30, the
 *                       extra being the declination not being exactly zero at
 *                       solar noon on the day the equinox happens to fall)
 *   December solstice   90 - 37.7749 - 23.44 = 28.79°   (returned: 28.82)
 *
 * The returned figures run a hundredth of a degree over the geometric ones at
 * noon and about four tenths over at the horizon; that gap is the refraction
 * term, and it is the whole reason a sunset you can watch has already happened.
 *
 * The equation of time reaches about -14.2 min around 11 February and +16.5 min
 * around 3 November, and passes through zero near 15 April, 13 June, 1
 * September and 25 December. Solar noon in San Francisco therefore lands near
 * 20:10 UTC in mid-April and near 19:53 UTC in early November — a seventeen
 * minute swing in when noon is, from a clock that never moves.
 *
 * `sunTimes` for San Francisco at the June solstice: sunrise 12:48 UTC, sunset
 * 03:35 UTC the following day — 05:48 and 20:35 Pacific — a day 14 h 47 m long.
 * At the December solstice: 15:21 and 00:54 UTC, which is 07:21 and 16:54
 * Pacific and 9 h 33 m of daylight.
 *
 * Tromsø at 69.65° N returns `null` for both events from 18 May to 26 July and
 * again from 28 November to 15 January, which is the midnight sun and the polar
 * night to the day. `solarNoon` is still returned in every one of those cases,
 * and the poles themselves return nulls rather than a NaN or a throw.
 *
 * `sunDirection` of an azimuth of 180 and an elevation of 0 is `{x: 0, y: 0,
 * z: 1}` — due south is +z. Azimuth 90 gives +x, east; azimuth 0 gives -z.
 */
