📄 src/Integrations/Smhi/SmhiClient.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;

namespace MMirror.Integrations.Smhi;

public sealed class SmhiClient(HttpClient httpClient, TimeProvider timeProvider)
{
    private const double MissingValue = 9999;

    private static readonly DayPeriod[] PeriodOrder =
    [
        DayPeriod.Morgon,
        DayPeriod.Middag,
        DayPeriod.Eftermiddag,
        DayPeriod.Natt,
    ];

    public async Task<PointForecast> GetPointForecast(
        double longitude,
        double latitude,
        CancellationToken cancellationToken
    )
    {
        var response =
            await httpClient.GetFromJsonAsync(
                $"/api/category/snow1g/version/1/geotype/point/lon/{longitude.ToString("F6", CultureInfo.InvariantCulture)}/lat/{latitude.ToString("F6", CultureInfo.InvariantCulture)}/data.json?parameters=air_temperature,symbol_code,precipitation_amount_mean",
                SmhiJsonSerializerContext.Default.ForecastResponse,
                cancellationToken
            ) ?? throw new Exception("Received literal null from SMHI point forecast endpoint.");

        var periods = response.TimeSeries.Select(TryMapPeriod).OfType<ForecastPeriod>().ToArray();

        if (periods.Length < 3)
        {
            throw new Exception("SMHI point forecast returned fewer than 3 usable time series entries.");
        }

        var localNow = timeProvider.GetLocalNow();
        var currentPeriod = GetCurrentPeriod(localNow);
        var currentAnchor = localNow;
        var nextPeriod = PeriodAtOffset(currentPeriod, 1);
        var nextAnchor = GetFollowingPeriodAnchor(nextPeriod, currentAnchor);
        var followingPeriod = PeriodAtOffset(currentPeriod, 2);
        var followingAnchor = GetFollowingPeriodAnchor(followingPeriod, nextAnchor);

        return new PointForecast(
            CreateLabeledPeriod(currentPeriod, currentAnchor, periods, preferFuture: true),
            CreateLabeledPeriod(nextPeriod, nextAnchor, periods),
            CreateLabeledPeriod(followingPeriod, followingAnchor, periods)
        );
    }

    private static LabeledForecastPeriod CreateLabeledPeriod(
        DayPeriod period,
        DateTimeOffset anchor,
        ForecastPeriod[] periods,
        bool preferFuture = false
    ) => new(period, GetPeriodName(period), FindClosestPeriod(periods, anchor.ToUniversalTime(), preferFuture));

    private static DayPeriod GetCurrentPeriod(DateTimeOffset localNow) =>
        localNow.Hour switch
        {
            >= 3 and < 9 => DayPeriod.Morgon,
            >= 9 and < 15 => DayPeriod.Middag,
            >= 15 and < 21 => DayPeriod.Eftermiddag,
            _ => DayPeriod.Natt,
        };

    private static DateTimeOffset GetFollowingPeriodAnchor(DayPeriod period, DateTimeOffset previousAnchor)
    {
        var day = DateOnly.FromDateTime(previousAnchor.LocalDateTime);

        for (var dayOffset = 0; dayOffset <= 2; dayOffset++)
        {
            var candidate = MakeAnchor(day.AddDays(dayOffset), period, previousAnchor.Offset);
            if (candidate > previousAnchor)
            {
                return candidate;
            }
        }

        throw new Exception($"No upcoming anchor found for {period} after {previousAnchor}.");
    }

    private static DateTimeOffset MakeAnchor(DateOnly day, DayPeriod period, TimeSpan offset) =>
        new(
            day.ToDateTime(
                new TimeOnly(
                    period switch
                    {
                        DayPeriod.Morgon => 6,
                        DayPeriod.Middag => 12,
                        DayPeriod.Eftermiddag => 18,
                        DayPeriod.Natt => 0,
                        _ => throw new ArgumentOutOfRangeException(nameof(period)),
                    },
                    0
                )
            ),
            offset
        );

    private static DayPeriod PeriodAtOffset(DayPeriod current, int offset) =>
        PeriodOrder[(Array.IndexOf(PeriodOrder, current) + offset) % PeriodOrder.Length];

    private static string GetPeriodName(DayPeriod period) =>
        period switch
        {
            DayPeriod.Morgon => "Morgon",
            DayPeriod.Middag => "Middag",
            DayPeriod.Eftermiddag => "Eftermiddag",
            DayPeriod.Natt => "Natt",
            _ => throw new ArgumentOutOfRangeException(nameof(period)),
        };

    private static ForecastPeriod? TryMapPeriod(TimeSeriesEntry entry)
    {
        if (
            entry.Data.AirTemperature is not double temperature
            || IsMissing(temperature)
            || entry.Data.SymbolCode is not int symbolCode
            || symbolCode is < 1 or > 27
            || entry.Data.PrecipitationAmountMean is not double precipitation
            || IsMissing(precipitation)
        )
        {
            return null;
        }

        return new ForecastPeriod(entry.Time, temperature, precipitation, (WeatherSymbol)symbolCode);
    }

    private static bool IsMissing(double value) => double.Abs(value - MissingValue) < 0.001;

    private static ForecastPeriod FindClosestPeriod(ForecastPeriod[] periods, DateTimeOffset target, bool preferFuture)
    {
        IEnumerable<ForecastPeriod> filteredPeriods = periods;
        if (preferFuture)
        {
            filteredPeriods = filteredPeriods.Where(period => period.ValidTime >= target);
        }

        return filteredPeriods.MinBy(period => double.Abs((period.ValidTime - target).TotalMinutes))
            ?? throw new Exception($"No forecast found for {target}.");
    }
}

public sealed record PointForecast(
    LabeledForecastPeriod Now,
    LabeledForecastPeriod Next,
    LabeledForecastPeriod Following
);

public sealed record LabeledForecastPeriod(DayPeriod Period, string PeriodName, ForecastPeriod Forecast);

public enum DayPeriod
{
    Morgon,
    Middag,
    Eftermiddag,
    Natt,
}

public sealed record ForecastPeriod(
    DateTimeOffset ValidTime,
    double TemperatureCelsius,
    double PrecipitationMillimeters,
    WeatherSymbol Symbol
);

public enum WeatherSymbol
{
    ClearSky = 1,
    NearlyClearSky = 2,
    VariableCloudiness = 3,
    HalfclearSky = 4,
    CloudySky = 5,
    Overcast = 6,
    Fog = 7,
    LightRainShowers = 8,
    ModerateRainShowers = 9,
    HeavyRainShowers = 10,
    Thunderstorm = 11,
    LightSleetShowers = 12,
    ModerateSleetShowers = 13,
    HeavySleetShowers = 14,
    LightSnowShowers = 15,
    ModerateSnowShowers = 16,
    HeavySnowShowers = 17,
    LightRain = 18,
    ModerateRain = 19,
    HeavyRain = 20,
    Thunder = 21,
    LightSleet = 22,
    ModerateSleet = 23,
    HeavySleet = 24,
    LightSnowfall = 25,
    ModerateSnowfall = 26,
    HeavySnowfall = 27,
}