/* weather-nws.c - National Weather Service (USA)
 *
 * SPDX-FileCopyrightText: The GWeather authors
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

#include "config.h"

#include "gweather-private.h"

#include <stdio.h>

#include <glib.h>

#include <json-glib/json-glib.h>

#define JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS(in, name, tmpl, tmpl_suffix, ...)  \
    if (!json_object_has_member (in, name)) {                                      \
        g_warning ("Member `" #tmpl "` does not exist" #tmpl_suffix, __VA_ARGS__); \
        return;                                                                    \
    }

#define JSON_OBJECT_GET_MEMBER_OR_RETURN(article, type, in, name, out, tmpl, tmpl_suffix, ...)  \
    JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (in, name, tmpl, tmpl_suffix, __VA_ARGS__)          \
    out = json_object_get_##type##_member (in, name);                                           \
    if (out == NULL) {                                                                          \
        g_warning ("Value at `" #tmpl "` is not " article " " #type #tmpl_suffix, __VA_ARGS__); \
        return;                                                                                 \
    }

#define JSON_OBJECT_GET_ARRAY_MEMBER_OR_RETURN(in, name, out, tmpl, tmpl_suffix, ...) \
    JSON_OBJECT_GET_MEMBER_OR_RETURN ("an", array, in, name, out, tmpl, tmpl_suffix, __VA_ARGS__)
#define JSON_OBJECT_GET_OBJECT_MEMBER_OR_RETURN(in, name, out, tmpl, tmpl_suffix, ...) \
    JSON_OBJECT_GET_MEMBER_OR_RETURN ("an", object, in, name, out, tmpl, tmpl_suffix, __VA_ARGS__)
#define JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN(in, name, out, tmpl, tmpl_suffix, ...) \
    JSON_OBJECT_GET_MEMBER_OR_RETURN ("a", string, in, name, out, tmpl, tmpl_suffix, __VA_ARGS__)

#define JSON_ARRAY_GET_OBJECT_ELEMENT_OR_RETURN(in, index, out, tmpl, ...) \
    out = json_array_get_object_element (in, index);                       \
    if (out == NULL) {                                                     \
        g_warning ("Value at `" #tmpl "` is not an object", __VA_ARGS__);  \
        return;                                                            \
    }

typedef struct _TimePair
{
    time_t start;
    time_t end;
} TimePair;

/* The documented values for properties.weather.values.*.value.*.weather in the
 * responses generated by the /gridpoints endpoint.
 *
 * See https://www.weather.gov/documentation/services-web-api#/default/gridpoint
 */
typedef enum
{
    NWS_WEATHER_NULL,

    NWS_WEATHER_BLOWING_DUST,
    NWS_WEATHER_BLOWING_SAND,
    NWS_WEATHER_BLOWING_SNOW,
    NWS_WEATHER_DRIZZLE,
    NWS_WEATHER_FOG,
    NWS_WEATHER_FREEZING_DRIZZLE,
    NWS_WEATHER_FREEZING_FOG,
    NWS_WEATHER_FREEZING_RAIN,
    NWS_WEATHER_FREEZING_SPRAY,
    NWS_WEATHER_FROST,
    NWS_WEATHER_HAIL,
    NWS_WEATHER_HAZE,
    NWS_WEATHER_ICE_CRYSTALS,
    NWS_WEATHER_ICE_FOG,
    NWS_WEATHER_RAIN,
    NWS_WEATHER_RAIN_SHOWERS,
    NWS_WEATHER_SLEET,
    NWS_WEATHER_SMOKE,
    NWS_WEATHER_SNOW,
    NWS_WEATHER_SNOW_SHOWERS,
    NWS_WEATHER_THUNDERSTORMS,
    NWS_WEATHER_VOLCANIC_ASH,
    NWS_WEATHER_WATER_SPOUTS,

    NWS_WEATHER_UNRECOGNIZED
} NwsWeather;

static NwsWeather
parse_nws_weather (const gchar *str)
{
    if (str == NULL)
        return NWS_WEATHER_NULL;

    switch (str[0]) {
        case 'b':
            if (strncmp (str + 1, "lowing_", 7))
                break;
            switch (str[8]) {
                case 'd':
                    if (strcmp (str + 9, "ust"))
                        break;
                    return NWS_WEATHER_BLOWING_DUST;
                case 's':
                    switch (str[9]) {
                        case 'a':
                            if (strcmp (str + 10, "nd"))
                                break;
                            return NWS_WEATHER_BLOWING_SAND;
                        case 'n':
                            if (strcmp (str + 10, "ow"))
                                break;
                            return NWS_WEATHER_BLOWING_SNOW;
                    }
                    break;
            }
            break;
        case 'd':
            if (strcmp (str + 1, "rizzle"))
                break;
            return NWS_WEATHER_DRIZZLE;
        case 'f':
            switch (str[1]) {
                case 'o':
                    if (strcmp (str + 2, "g"))
                        break;
                    return NWS_WEATHER_FOG;
                case 'r':
                    switch (str[2]) {
                        case 'e':
                            if (strncmp (str + 3, "ezing_", 6))
                                break;
                            switch (str[9]) {
                                case 'd':
                                    if (strcmp (str + 10, "rizzle"))
                                        break;
                                    return NWS_WEATHER_FREEZING_DRIZZLE;
                                case 'f':
                                    if (strcmp (str + 10, "og"))
                                        break;
                                    return NWS_WEATHER_FREEZING_FOG;
                                case 'r':
                                    if (strcmp (str + 10, "ain"))
                                        break;
                                    return NWS_WEATHER_FREEZING_RAIN;
                                case 's':
                                    if (strcmp (str + 10, "pray"))
                                        break;
                                    return NWS_WEATHER_FREEZING_SPRAY;
                            }
                            break;
                        case 'o':
                            if (strcmp (str + 3, "st"))
                                break;
                            return NWS_WEATHER_FROST;
                    }
                    break;
            }
            break;
        case 'h':
            if (str[1] != 'a')
                break;
            switch (str[2]) {
                case 'i':
                    if (strcmp (str + 3, "l"))
                        break;
                    return NWS_WEATHER_HAIL;
                case 'z':
                    if (strcmp (str + 3, "e"))
                        break;
                    return NWS_WEATHER_HAZE;
            }
            break;
        case 'i':
            if (strncmp (str + 1, "ce_", 3))
                break;
            switch (str[4]) {
                case 'c':
                    if (strcmp (str + 5, "rystals"))
                        break;
                    return NWS_WEATHER_ICE_CRYSTALS;
                case 'f':
                    if (strcmp (str + 5, "og"))
                        break;
                    return NWS_WEATHER_ICE_FOG;
            }
            break;
        case 'r':
            if (strncmp (str + 1, "ain", 3))
                break;
            switch (str[4]) {
                case '\0':
                    return NWS_WEATHER_RAIN;
                case '_':
                    if (strcmp (str + 5, "showers"))
                        break;
                    return NWS_WEATHER_RAIN_SHOWERS;
            }
            break;
        case 's':
            switch (str[1]) {
                case 'l':
                    if (strcmp (str + 2, "eet"))
                        break;
                    return NWS_WEATHER_SLEET;
                case 'm':
                    if (strcmp (str + 2, "oke"))
                        break;
                    return NWS_WEATHER_SMOKE;
                case 'n':
                    if (strncmp (str + 2, "ow", 2))
                        break;
                    switch (str[4]) {
                        case '\0':
                            return NWS_WEATHER_SNOW;
                        case '_':
                            if (strcmp (str + 5, "showers"))
                                break;
                            return NWS_WEATHER_SNOW_SHOWERS;
                    }
                    break;
            }
            break;
        case 't':
            if (strcmp (str + 1, "hunderstorms"))
                break;
            return NWS_WEATHER_THUNDERSTORMS;
        case 'v':
            if (strcmp (str + 1, "olcanic_ash"))
                break;
            return NWS_WEATHER_VOLCANIC_ASH;
        case 'w':
            if (strcmp (str + 1, "ater_spouts"))
                break;
            return NWS_WEATHER_WATER_SPOUTS;
    }
    return NWS_WEATHER_UNRECOGNIZED;
}

/**
 * times_from_iso8601_interval:
 *
 * A very incomplete parser for the ISO 8601 format for time intervals like
 * 2022-01-13T18:00:00Z/PT6H. Doesn't support a lot of the more advanced
 * features of the standard. Replacing this with a function in some other
 * library would be a great idea.
 *
 * Return value: a pair of time_t values representing the start and the end of
 * the interval.
 **/
static TimePair
times_from_iso8601_interval (const gchar *str)
{
    TimePair ret = { 0, 0 };
    const gchar *sep;
    gchar *date_part;
    g_autoptr (GDateTime) dt_start = NULL;
    g_autoptr (GDateTime) dt_end = NULL;

    sep = strchr (str, '/');
    if (sep == NULL)
        return ret;

    date_part = g_strndup (str, sep - str);
    dt_start = g_date_time_new_from_iso8601 (date_part, NULL);
    g_free (date_part);
    ret.start = g_date_time_to_unix (dt_start);

    if (*(sep + 1) == 'P') {
        gint years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0;
        gboolean in_date = TRUE;
        for (const gchar *ptr = sep + 2; *ptr != '\0';) {
            if (*ptr == 'T') {
                in_date = FALSE;
                ptr++;
            } else {
                int num, advanced;
                gchar field;
                sscanf (ptr, "%d%c%n", &num, &field, &advanced);
                if (in_date) {
                    switch (field) {
                        case 'Y':
                            years = num;
                            break;
                        case 'M':
                            months = num;
                            break;
                        case 'D':
                            days = num;
                            break;
                        default:
                            ret.end = ret.start;
                            return ret;
                    }
                } else {
                    switch (field) {
                        case 'H':
                            hours = num;
                            break;
                        case 'M':
                            minutes = num;
                            break;
                        case 'S':
                            seconds = num;
                            break;
                        default:
                            ret.end = ret.start;
                            return ret;
                    }
                }
                ptr += advanced;
            }
        }
        dt_end = g_date_time_add_full (dt_start, years, months, days, hours, minutes, (gdouble) seconds);
    } else {
        dt_end = g_date_time_new_from_iso8601 (sep + 1, NULL);
    }

    ret.end = g_date_time_to_unix (dt_end);
    return ret;
}

static gboolean
json_array_contains_string (JsonArray *arr, const gchar *str)
{
    guint len;
    const gchar *str2;

    len = json_array_get_length (arr);
    for (guint i = 0; i < len; i++) {
        str2 = json_array_get_string_element (arr, i);
        if (str2 != NULL && strcmp (str, str2) == 0) {
            return TRUE;
        }
    }

    return FALSE;
}

static SoupMessage *
nws_new_request (GWeatherInfo *info, const gchar *url)
{
    SoupMessage *message;
    SoupMessageHeaders *headers;

    message = soup_message_new ("GET", url);
    _gweather_info_begin_request (info, message);
#if SOUP_CHECK_VERSION(2, 99, 2)
    headers = soup_message_get_request_headers (message);
#else
    headers = message->request_headers;
#endif
    soup_message_headers_append (headers, "Accept", "application/geo+json");

    return message;
}

typedef void (*ValueReader) (GWeatherInfo *, JsonNode *);

static void
read_interval_values (GSList *forecast_list,
                      JsonObject *obj,
                      gchar *property_name,
                      ValueReader (*select_read_value) (const gchar *),
                      void (*copy_value) (GWeatherInfo *, GWeatherInfo *))
{
    JsonObject *prop;
    JsonArray *arr;
    const gchar *uom;
    const gchar *valid_time;
    ValueReader read_value;
    guint len;
    JsonObject *datum;
    TimePair range;
    guint i = 0;
    GWeatherInfo *prev = NULL;
    GWeatherInfo *info;

    JSON_OBJECT_GET_OBJECT_MEMBER_OR_RETURN (obj, property_name, prop, "%s", "", property_name)

    JSON_OBJECT_GET_ARRAY_MEMBER_OR_RETURN (prop, "values", arr, "%s.values", "", property_name)

    uom = NULL;
    if (json_object_has_member (prop, "uom")) {
        uom = json_object_get_string_member (prop, "uom");
    }

    read_value = (*select_read_value) (uom);
    len = json_array_get_length (arr);
    if (len > 0) {
        JSON_ARRAY_GET_OBJECT_ELEMENT_OR_RETURN (arr, i, datum, "%s.values[%d]", property_name, i)

        JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN (datum, "validTime", valid_time, "%s.values[%d].validTime", "", property_name, i)

        range = times_from_iso8601_interval (valid_time);
        for (GSList *slist = forecast_list; slist != NULL; slist = slist->next) {
            info = slist->data;
            while (info->current_time >= range.end) {
                i++;
                if (i >= len) {
                    return;
                }
                JSON_ARRAY_GET_OBJECT_ELEMENT_OR_RETURN (arr, i, datum, "%s.values[%d]", property_name, i)

                JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN (datum, "validTime", valid_time, "%s.values[%d].validTime", "", property_name, i)

                range = times_from_iso8601_interval (valid_time);
                prev = NULL;
            }
            if (info->current_time >= range.start) {
                if (prev == NULL) {
                    JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (datum, "value", "%s.values[%d].value", "", property_name, i)
                    (*read_value) (info, json_object_get_member (datum, "value"));
                } else {
                    (*copy_value) (info, prev);
                }
                prev = info;
            }
        }
    }
}

static void
read_temperature_f (GWeatherInfo *info, JsonNode *node)
{
    info->temp = json_node_get_double (node);
}

static void
read_temperature_c (GWeatherInfo *info, JsonNode *node)
{
    info->temp = TEMP_C_TO_F (json_node_get_double (node));
}

static ValueReader
select_read_temperature (const gchar *uom)
{
    if (g_strcmp0 (uom, "wmoUnit:degC") == 0) {
        return read_temperature_c;
    }
    return read_temperature_f;
}

static void
copy_temperature (GWeatherInfo *info, GWeatherInfo *prev)
{
    info->temp = prev->temp;
}

static void
read_dew_f (GWeatherInfo *info, JsonNode *node)
{
    info->dew = json_node_get_double (node);
}

static void
read_dew_c (GWeatherInfo *info, JsonNode *node)
{
    info->dew = TEMP_C_TO_F (json_node_get_double (node));
}

static ValueReader
select_read_dew (const gchar *uom)
{
    if (g_strcmp0 (uom, "wmoUnit:degC") == 0) {
        return read_dew_c;
    }
    return read_dew_f;
}

static void
copy_dew (GWeatherInfo *info, GWeatherInfo *prev)
{
    info->dew = prev->dew;
}

static void
read_weather (GWeatherInfo *info, JsonNode *node)
{
    GWeatherConditions conditions = { FALSE, GWEATHER_PHENOMENON_NONE, GWEATHER_QUALIFIER_NONE };
    JsonArray *arr;
    guint len;
    JsonObject *obj;
    NwsWeather weather;
    const gchar *coverage;
    const gchar *intensity;
    GWeatherConditionQualifier intensity_qualifier = GWEATHER_QUALIFIER_NONE;
    JsonArray *attributes;

    if (!JSON_NODE_HOLDS_ARRAY (node)) {
        g_warning ("Value is not an array");
        return;
    }
    arr = json_node_get_array (node);
    len = json_array_get_length (arr);

    // Deeply imperfect, but: for the first element in the array that produces
    // a coherent GWeatherConditions, use it. If there's another element in the
    // array with broader coverage, higher intensity, or is just more dramatic
    // (tornadoes!), too bad.
    //
    // A smarter approach might take such things into account, and attempt to
    // report on the most overall significant weather phenomenon based on some
    // prioritization of entries in this array, or perhaps merge them in some
    // way.
    for (guint i = 0; i < len; i++) {
        JSON_ARRAY_GET_OBJECT_ELEMENT_OR_RETURN (arr, i, obj, "[%d]", i)

        // Can be NULL; parse_nws_weather maps NULL to NWS_WEATHER_NULL
        JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (obj, "weather", "[%d].weather", "", i)
        weather = parse_nws_weather (json_object_get_string_member (obj, "weather"));

        // NULL or one of: areas, brief, chance, definite, few, frequent,
        // intermittent, isolated, likely, numerous, occasional, patchy,
        // periods, scattered, slight_chance, widespread
        JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (obj, "coverage", "[%d].coverage", "", i)
        coverage = json_object_get_string_member (obj, "coverage");
        if (coverage != NULL) {
            // We don't want to use weather conditions if they come with a
            // coverage keyword that indicates the conditions are less likely
            // than not to come to pass. The values chosen to be excluded are
            // taken from the table here:
            // https://vlab.noaa.gov/web/mdl/weather-type-definitions
            if (strcmp (coverage, "slight_chance") == 0 ||
                strcmp (coverage, "chance") == 0 ||
                strcmp (coverage, "isolated") == 0 ||
                strcmp (coverage, "scattered") == 0) {
                continue;
            }
        }

        // NULL or one of: very_light, light, moderate, heavy
        JSON_OBJECT_RETURN_UNLESS_MEMBER_EXISTS (obj, "intensity", "[%d].intensity", "", i)
        intensity = json_object_get_string_member (obj, "intensity");
        if (intensity != NULL) {
            if (strcmp (intensity, "very_light") == 0 || strcmp (intensity, "light") == 0) {
                intensity_qualifier = GWEATHER_QUALIFIER_LIGHT;
            } else if (strcmp (intensity, "moderate") == 0) {
                intensity_qualifier = GWEATHER_QUALIFIER_MODERATE;
            } else if (strcmp (intensity, "heavy") == 0) {
                intensity_qualifier = GWEATHER_QUALIFIER_HEAVY;
            }
        }

        // Array of damaging_wind, dry_thunderstorms, flooding, gusty_wind, heavy_rain, large_hail, small_hail, tornadoes
        JSON_OBJECT_GET_ARRAY_MEMBER_OR_RETURN (obj, "attributes", attributes, "[%d].attributes", "", i)

        switch (weather) {
            case NWS_WEATHER_FREEZING_DRIZZLE:
            case NWS_WEATHER_DRIZZLE:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_DRIZZLE;
                if (weather == NWS_WEATHER_FREEZING_DRIZZLE) {
                    conditions.qualifier = GWEATHER_QUALIFIER_FREEZING;
                } else if (intensity_qualifier != GWEATHER_QUALIFIER_NONE) {
                    conditions.qualifier = intensity_qualifier;
                }
                break;
            case NWS_WEATHER_FREEZING_RAIN:
            case NWS_WEATHER_RAIN:
            case NWS_WEATHER_RAIN_SHOWERS:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_RAIN;
                if (weather == NWS_WEATHER_FREEZING_RAIN) {
                    conditions.qualifier = GWEATHER_QUALIFIER_FREEZING;
                } else if (weather == NWS_WEATHER_RAIN_SHOWERS) {
                    conditions.qualifier = GWEATHER_QUALIFIER_SHOWERS;
                } else if (intensity_qualifier != GWEATHER_QUALIFIER_NONE) {
                    conditions.qualifier = intensity_qualifier;
                }
                break;
            case NWS_WEATHER_BLOWING_SNOW:
            case NWS_WEATHER_SNOW:
            case NWS_WEATHER_SNOW_SHOWERS:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_SNOW;
                if (weather == NWS_WEATHER_BLOWING_SNOW) {
                    conditions.qualifier = GWEATHER_QUALIFIER_BLOWING;
                } else if (weather == NWS_WEATHER_SNOW_SHOWERS) {
                    conditions.qualifier = GWEATHER_QUALIFIER_SHOWERS;
                } else if (intensity_qualifier != GWEATHER_QUALIFIER_NONE) {
                    conditions.qualifier = intensity_qualifier;
                }
                break;
            case NWS_WEATHER_ICE_CRYSTALS:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_ICE_CRYSTALS;
                break;
            case NWS_WEATHER_SLEET:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_ICE_PELLETS;
                if (intensity_qualifier != GWEATHER_QUALIFIER_NONE) {
                    conditions.qualifier = intensity_qualifier;
                }
                break;
            case NWS_WEATHER_HAIL:
                conditions.significant = TRUE;
                if (json_array_contains_string (attributes, "small_hail")) {
                    conditions.phenomenon = GWEATHER_PHENOMENON_SMALL_HAIL;
                } else {
                    conditions.phenomenon = GWEATHER_PHENOMENON_HAIL;
                }
                break;
            case NWS_WEATHER_FOG:
            case NWS_WEATHER_FREEZING_FOG:
            case NWS_WEATHER_ICE_FOG:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_FOG;
                if (weather != NWS_WEATHER_FOG) {
                    conditions.qualifier = GWEATHER_QUALIFIER_FREEZING;
                } else if (strcmp (coverage, "areas") == 0) {
                    conditions.qualifier = GWEATHER_QUALIFIER_PARTIAL;
                } else if (strcmp (coverage, "patchy") == 0) {
                    conditions.qualifier = GWEATHER_QUALIFIER_PATCHES;
                }
                break;
            case NWS_WEATHER_SMOKE:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_SMOKE;
                break;
            case NWS_WEATHER_VOLCANIC_ASH:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_VOLCANIC_ASH;
                break;
            case NWS_WEATHER_BLOWING_SAND:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_SAND;
                conditions.qualifier = GWEATHER_QUALIFIER_BLOWING;
                break;
            case NWS_WEATHER_HAZE:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_HAZE;
                break;
            case NWS_WEATHER_FREEZING_SPRAY:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_SPRAY;
                conditions.qualifier = GWEATHER_QUALIFIER_FREEZING;
                break;
            case NWS_WEATHER_BLOWING_DUST:
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_DUST;
                conditions.qualifier = GWEATHER_QUALIFIER_BLOWING;
                break;
            case NWS_WEATHER_THUNDERSTORMS:
                conditions.significant = TRUE;
                conditions.qualifier = GWEATHER_QUALIFIER_THUNDERSTORM;
                break;
            case NWS_WEATHER_NULL:
            case NWS_WEATHER_FROST:
            case NWS_WEATHER_WATER_SPOUTS:
            case NWS_WEATHER_UNRECOGNIZED:
                break;
        }
        if (conditions.phenomenon == GWEATHER_PHENOMENON_NONE) {
            if (json_array_contains_string (attributes, "tornadoes")) {
                conditions.significant = TRUE;
                conditions.phenomenon = GWEATHER_PHENOMENON_TORNADO;
            }
        }

        if (conditions.significant) {
            break;
        }
    }
    info->cond = conditions;
}

static ValueReader
select_read_weather (const gchar *uom)
{
    return read_weather;
}

static void
copy_weather (GWeatherInfo *info, GWeatherInfo *prev)
{
    info->cond = prev->cond;
}

static void
read_sky (GWeatherInfo *info, JsonNode *node)
{
    gdouble pct = json_node_get_double (node);
    if (pct >= 0 && pct <= 100) {
        if (pct < 12.5) {
            info->sky = GWEATHER_SKY_CLEAR;
        } else if (pct < 37.5) {
            info->sky = GWEATHER_SKY_BROKEN;
        } else if (pct < 62.5) {
            info->sky = GWEATHER_SKY_SCATTERED;
        } else if (pct < 87.5) {
            info->sky = GWEATHER_SKY_FEW;
        } else {
            info->sky = GWEATHER_SKY_OVERCAST;
        }
    }
}

static ValueReader
select_read_sky (const gchar *uom)
{
    return read_sky;
}

static void
copy_sky (GWeatherInfo *info, GWeatherInfo *prev)
{
    info->sky = prev->sky;
}

static void
read_winddir (GWeatherInfo *info, JsonNode *node)
{
    gdouble wind = json_node_get_double (node);
    if (wind >= 0 && wind < 360) {
        if (wind >= 348.75) {
            info->wind = GWEATHER_WIND_N;
        } else {
            info->wind = GWEATHER_WIND_N + (int) ((wind + 11.25) / 22.5);
        }
    }
}

static ValueReader
select_read_winddir (const gchar *uom)
{
    return read_winddir;
}

static void
copy_winddir (GWeatherInfo *info, GWeatherInfo *prev)
{
    info->wind = prev->wind;
}

static void
read_windspeed (GWeatherInfo *info, JsonNode *node)
{
    gdouble windspeed_kph = json_node_get_double (node);
    info->windspeed = WINDSPEED_MS_TO_KNOTS (windspeed_kph / 3.6);
}

static ValueReader
select_read_windspeed (const gchar *uom)
{
    return read_windspeed;
}

static void
copy_windspeed (GWeatherInfo *info, GWeatherInfo *prev)
{
    info->windspeed = prev->windspeed;
}

static void
read_humidity (GWeatherInfo *info, JsonNode *node)
{
    info->humidity = json_node_get_double (node);
    info->hasHumidity = TRUE;
}

static ValueReader
select_read_humidity (const gchar *uom)
{
    return read_humidity;
}

static void
copy_humidity (GWeatherInfo *info, GWeatherInfo *prev)
{
    info->humidity = prev->humidity;
    info->hasHumidity = prev->hasHumidity;
}

static void
read_visibility_m (GWeatherInfo *info, JsonNode *node)
{
    info->visibility = json_node_get_double (node) / VISIBILITY_SM_TO_M (1.);
}

static void
read_visibility_sm (GWeatherInfo *info, JsonNode *node)
{
    info->visibility = json_node_get_double (node);
}

static ValueReader
select_read_visibility (const gchar *uom)
{
    if (g_strcmp0 (uom, "wmoUnit:m") == 0) {
        return read_visibility_m;
    }
    return read_visibility_sm;
}

static void
copy_visibility (GWeatherInfo *info, GWeatherInfo *prev)
{
    info->visibility = prev->visibility;
}

static void
nws_finish_forecast_common (GWeatherInfo *info,
                            const char *content,
                            gsize body_size)
{
    WeatherLocation *loc;
    JsonNode *root;
    JsonObject *obj;
    const char *valid_times;
    TimePair range;
    GWeatherInfo *info2;
    guint num_forecasts = 0;

    loc = &info->location;
    g_debug ("nws gridpoint data for %lf, %lf", loc->latitude, loc->longitude);
    g_debug ("%s", content);

    g_autoptr (JsonParser) parser = json_parser_new ();
    g_autoptr (GError) error = NULL;
    if (!json_parser_load_from_data (parser, content, body_size, &error)) {
        g_warning ("Failed to parse response from weather.gov: %s", error->message);
        return;
    }

    root = json_parser_get_root (parser);
    if (!JSON_NODE_HOLDS_OBJECT (root)) {
        g_warning ("Response from weather.gov is not an object: %s", content);
        return;
    }
    obj = json_node_get_object (root);

    JSON_OBJECT_GET_OBJECT_MEMBER_OR_RETURN (obj, "properties", obj, "properties", ": %s", content)

    /* The gridpoints API uses an encoding in which each weather variable
     * is an array of values tagged with time *intervals*. So far, all of
     * the intervals I've seen have been multiples of an hour, although
     * this isn't guaranteed by the API specification. The intervals are
     * *not* uniform, and different weather variables are often broken into
     * different time intervals (based, perhaps, on whether there is an
     * actual change forecasted from one hour to the next).
     *
     * So, to decode this, we create one forecast info object for every
     * hour in the overall response's validTimes range, and then repeatedly
     * iterate over the list of infos, populating the info object one
     * variable at a time.
     */

    JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN (obj, "validTimes", valid_times, "properties.validTimes", ": %s", content)

    range = times_from_iso8601_interval (valid_times);
    // POSIX time doesn't care about leap seconds, neither does GLib, so
    // whatever, an hour is always 3600 seconds.
    for (time_t t = range.start; t < range.end; t += 3600) {
        info2 = _gweather_info_new_clone (info);
        info2->valid = TRUE;
        info2->current_time = info2->update = t;
        info->forecast_list = g_slist_prepend (info->forecast_list, info2);
        num_forecasts++;
    }
    info->forecast_list = g_slist_reverse (info->forecast_list);

    read_interval_values (info->forecast_list, obj, "weather", select_read_weather, copy_weather);
    read_interval_values (info->forecast_list, obj, "temperature", select_read_temperature, copy_temperature);
    read_interval_values (info->forecast_list, obj, "dewpoint", select_read_dew, copy_dew);
    read_interval_values (info->forecast_list, obj, "skyCover", select_read_sky, copy_sky);
    read_interval_values (info->forecast_list, obj, "windDirection", select_read_winddir, copy_winddir);
    read_interval_values (info->forecast_list, obj, "windSpeed", select_read_windspeed, copy_windspeed);
    read_interval_values (info->forecast_list, obj, "relativeHumidity", select_read_humidity, copy_humidity);
    read_interval_values (info->forecast_list, obj, "visibility", select_read_visibility, copy_visibility);

    g_debug ("nws generated %d forecast infos", num_forecasts);
    if (!info->valid)
        info->valid = (num_forecasts > 0);
}

#if SOUP_CHECK_VERSION(2, 99, 2)
static void
nws_finish_forecast (GObject *source,
                     GAsyncResult *result,
                     gpointer data)
{
    GWeatherInfo *info;
    SoupSession *session = SOUP_SESSION (source);
    SoupMessage *msg = soup_session_get_async_result_message (session, result);
    GBytes *body;
    GError *error = NULL;
    const char *content;
    gsize body_size;

    body = soup_session_send_and_read_finish (session, result, &error);

    if (!body) {
        if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) {
            g_debug ("Failed to get weather.gov gridpoint data: %s", error->message);
            return;
        }
        g_message ("Failed to get weather.gov gridpoint data: %s", error->message);
        g_clear_error (&error);
        _gweather_info_request_done (data, msg);
        return;
    } else if (!SOUP_STATUS_IS_SUCCESSFUL (soup_message_get_status (msg))) {
        g_message ("Failed to get weather.gov gridpoint data: [status: %d] %s",
                   soup_message_get_status (msg),
                   soup_message_get_reason_phrase (msg));
        _gweather_info_request_done (data, msg);
        return;
    }

    content = g_bytes_get_data (body, &body_size);

    info = data;

    nws_finish_forecast_common (info, content, body_size);

    g_bytes_unref (body);
    _gweather_info_request_done (info, msg);
}
#else
static void
nws_finish_forecast (SoupSession *session,
                     SoupMessage *msg,
                     gpointer user_data)
{
    GWeatherInfo *info;

    info = user_data;

    if (!SOUP_STATUS_IS_SUCCESSFUL (msg->status_code)) {
        if (msg->status_code == SOUP_STATUS_CANCELLED) {
            g_debug ("Failed to get weather.gov gridpoint data: %s",
                     msg->reason_phrase);
            return;
        }
        g_debug ("Failed to get weather.gov gridpoint data: [status: %d]: %s",
                 msg->status_code,
                 msg->reason_phrase);
    } else {
        nws_finish_forecast_common (info, msg->response_body->data, msg->response_body->length);
    }

    _gweather_info_request_done (info, msg);
}
#endif

static void
nws_finish_new_common (GWeatherInfo *info,
                       const char *content,
                       gsize body_size)
{
    WeatherLocation *loc;
    JsonNode *root;
    JsonObject *obj;
    const gchar *url;
    SoupMessage *msg;

    loc = &info->location;
    g_debug ("nws data for %lf, %lf", loc->latitude, loc->longitude);
    g_debug ("%s", content);

    g_autoptr (JsonParser) parser = json_parser_new ();
    g_autoptr (GError) error = NULL;
    if (!json_parser_load_from_data (parser, content, body_size, &error)) {
        g_warning ("Failed to parse response from weather.gov: %s", error->message);
        return;
    }

    root = json_parser_get_root (parser);
    if (!JSON_NODE_HOLDS_OBJECT (root)) {
        g_warning ("Response from weather.gov is not an object: %s", content);
        return;
    }
    obj = json_node_get_object (root);

    JSON_OBJECT_GET_OBJECT_MEMBER_OR_RETURN (obj, "properties", obj, "properties", ": %s", content)

    // The endpoint at forecastGridData offers a superset of the
    // information available from the other endpoints, albeit in a more
    // complex format. Perhaps at some future date, the friendlier
    // forecastHourly endpoint will be sufficient.
    JSON_OBJECT_GET_STRING_MEMBER_OR_RETURN (obj, "forecastGridData", url, "properties.forecastGridData", ": %s", content)

    msg = nws_new_request (info, url);
    _gweather_info_queue_request (info, msg, nws_finish_forecast);
}

#if SOUP_CHECK_VERSION(2, 99, 2)
static void
nws_finish_new (GObject *source,
                GAsyncResult *result,
                gpointer data)
{
    GWeatherInfo *info;
    SoupSession *session = SOUP_SESSION (source);
    SoupMessage *msg = soup_session_get_async_result_message (session, result);
    GBytes *body;
    GError *error = NULL;
    const char *content;
    gsize body_size;

    body = soup_session_send_and_read_finish (session, result, &error);

    if (!body) {
        if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) {
            g_debug ("Failed to get weather.gov point data: %s", error->message);
            return;
        }
        g_message ("Failed to get weather.gov point data: %s", error->message);
        g_clear_error (&error);
        _gweather_info_request_done (data, msg);
        return;
    } else if (!SOUP_STATUS_IS_SUCCESSFUL (soup_message_get_status (msg))) {
        g_message ("Failed to get weather.gov point data: [status: %d] %s",
                   soup_message_get_status (msg),
                   soup_message_get_reason_phrase (msg));
        _gweather_info_request_done (data, msg);
        return;
    }

    content = g_bytes_get_data (body, &body_size);

    info = data;

    nws_finish_new_common (info, content, body_size);

    g_bytes_unref (body);
    _gweather_info_request_done (info, msg);
}
#else
static void
nws_finish_new (SoupSession *session,
                SoupMessage *msg,
                gpointer user_data)
{
    GWeatherInfo *info;

    info = user_data;

    if (!SOUP_STATUS_IS_SUCCESSFUL (msg->status_code)) {
        if (msg->status_code == SOUP_STATUS_CANCELLED) {
            g_debug ("Failed to get weather.gov point data: %s",
                     msg->reason_phrase);
            return;
        }
        g_debug ("Failed to get weather.gov point data: [status: %d]: %s",
                 msg->status_code,
                 msg->reason_phrase);
    } else {
        nws_finish_new_common (info, msg->response_body->data, msg->response_body->length);
    }

    _gweather_info_request_done (info, msg);
}
#endif

gboolean
nws_start_open (GWeatherInfo *info)
{
    gchar *url;
    SoupMessage *message;
    WeatherLocation *loc;
    g_autofree gchar *latstr = NULL;
    g_autofree gchar *lonstr = NULL;

    loc = &info->location;

    if (!loc->latlon_valid)
        return FALSE;

    /* see the description here: https://www.weather.gov/documentation/services-web-api */

    latstr = _radians_to_degrees_str (loc->latitude);
    lonstr = _radians_to_degrees_str (loc->longitude);

    url = g_strdup_printf ("https://api.weather.gov/points/%s%%2C%s", latstr, lonstr);
    g_debug ("nws_start_open, requesting: %s", url);

    message = nws_new_request (info, url);
    _gweather_info_queue_request (info, message, nws_finish_new);

    g_free (url);

    return TRUE;
}
