/**
 * api.weather.gov — the National Weather Service.
 *
 * The default source once a contact is configured, and the reason is licensing
 * rather than quality: NWS output is a work of the United States government and
 * is in the public domain, so nothing served downstream of it carries an
 * attribution or share-alike obligation. It is also keyless. The cost is that it
 * covers the US only, which is why `metno` exists.
 *
 * Getting an observation takes three hops — point to station list, station list
 * to nearest station, station to latest observation — so the first two are
 * resolved once per process and kept. Stations do not move.
 *
 * That station cache is keyed on the coordinate, which means its size is exactly
 * the number of places this box will answer for. It stays bounded because the
 * only coordinates that reach here are region centres from `regions.ts`: a
 * caller cannot name a point, so a caller cannot grow this map, and the three
 * hops are paid once per region for the life of the process rather than once per
 * curious request.
 */

import { getJson, userAgent } from "../http.ts";
import type { WeatherBody } from "../../../src/server/wire.ts";
import { deriveCondition, finiteOrNull } from "./condition.ts";

const BASE = "https://api.weather.gov";

interface PointsResponse {
  properties?: { observationStations?: string };
}

interface StationsResponse {
  features?: { properties?: { stationIdentifier?: string } }[];
}

interface Measurement {
  value?: number | null;
  unitCode?: string;
}

interface ObservationResponse {
  properties?: {
    timestamp?: string;
    temperature?: Measurement;
    windSpeed?: Measurement;
    windDirection?: Measurement;
    visibility?: Measurement;
    cloudLayers?: { amount?: string }[];
    presentWeather?: { weather?: string; intensity?: string | null }[];
  };
}

const stationCache = new Map<string, string>();

export async function fetchNws(
  lat: number,
  lng: number,
  contact: string,
): Promise<WeatherBody | null> {
  const headers = { "user-agent": userAgent(contact) };
  const station = await resolveStation(lat, lng, headers);
  if (station === null) return null;

  const obs = await getJson<ObservationResponse>(
    `${BASE}/stations/${encodeURIComponent(station)}/observations/latest`,
    { headers },
  );
  const p = obs?.properties;
  if (p === undefined) return null;

  const cloudCover = cloudFromLayers(p.cloudLayers);
  const present = p.presentWeather ?? [];
  const precipitation = precipitationFrom(present);
  const visibilityKm = metresToKm(p.visibility);

  return {
    observedAt: p.timestamp ?? new Date().toISOString(),
    source: "nws",
    synthetic: false,
    location: { lat, lng },
    temperatureC: finiteOrNull(p.temperature?.value),
    windKph: toKph(p.windSpeed),
    windDirDeg: finiteOrNull(p.windDirection?.value),
    cloudCover,
    precipitation,
    visibilityKm,
    condition: deriveCondition({
      cloudCover,
      precipitation,
      visibilityKm,
      frozen: present.some((w) => (w.weather ?? "").includes("snow")),
      thunder: present.some((w) => (w.weather ?? "").includes("thunder")),
    }),
    // No attribution block: US government works carry no such obligation, and
    // claiming one would be inventing a licence term.
  };
}

async function resolveStation(
  lat: number,
  lng: number,
  headers: Record<string, string>,
): Promise<string | null> {
  const key = `${lat.toFixed(4)},${lng.toFixed(4)}`;
  const cached = stationCache.get(key);
  if (cached !== undefined) return cached;

  const point = await getJson<PointsResponse>(`${BASE}/points/${key}`, { headers });
  const stationsUrl = point?.properties?.observationStations;
  if (stationsUrl === undefined) return null;

  const stations = await getJson<StationsResponse>(stationsUrl, { headers });
  // The list arrives nearest-first, which is the only ordering guarantee needed.
  const id = stations?.features?.[0]?.properties?.stationIdentifier;
  if (id === undefined) return null;

  stationCache.set(key, id);
  return id;
}

/** METAR sky cover, as a fraction. The reported layers are cumulative, so the
 *  densest one is the sky. */
function cloudFromLayers(layers: { amount?: string }[] | undefined): number {
  if (layers === undefined || layers.length === 0) return 0;
  let max = 0;
  for (const layer of layers) {
    const amount = layer.amount ?? "";
    const fraction =
      amount === "OVC" || amount === "VV"
        ? 1
        : amount === "BKN"
          ? 0.75
          : amount === "SCT"
            ? 0.4
            : amount === "FEW"
              ? 0.15
              : 0;
    if (fraction > max) max = fraction;
  }
  return max;
}

function precipitationFrom(present: { weather?: string; intensity?: string | null }[]): number {
  let max = 0;
  for (const entry of present) {
    const weather = entry.weather ?? "";
    if (!/rain|drizzle|snow|sleet|hail|thunder/.test(weather)) continue;
    const intensity = entry.intensity ?? "moderate";
    const value = intensity === "light" ? 0.3 : intensity === "heavy" ? 0.9 : 0.6;
    if (value > max) max = value;
  }
  return max;
}

/** Wind arrives as km/h from most stations and m/s from a few. Both are labelled. */
function toKph(m: Measurement | undefined): number | null {
  const value = finiteOrNull(m?.value);
  if (value === null) return null;
  return (m?.unitCode ?? "").includes("m_s") ? value * 3.6 : value;
}

function metresToKm(m: Measurement | undefined): number | null {
  const value = finiteOrNull(m?.value);
  return value === null ? null : value / 1000;
}
