Size: 15.9 KB Modified: 2/07/2026 9:10 PM
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using KaiChat.Models;

namespace KaiChat.Services;

public interface INightscoutService
{
    Task<NightscoutReadingsResult> GetLatestAsync(int count = 6, CancellationToken ct = default);
    Task<NightscoutReadingsResult> GetHistoryAsync(string? start, string? end, int count = 288, CancellationToken ct = default);
    Task<NightscoutStatusResult> GetStatusAsync(CancellationToken ct = default);
    string FormatReadingsForTool(NightscoutReadingsResult result);
    string FormatStatusForTool(NightscoutStatusResult result);
}

public class NightscoutService : INightscoutService
{
    private const int MaxLatestCount = 24;
    private const int MaxHistoryCount = 1000;
    private readonly HttpClient _http;
    private readonly KaiChatConfig _config;
    private readonly ILogger<NightscoutService> _logger;

    public NightscoutService(HttpClient http, KaiChatConfig config, ILogger<NightscoutService> logger)
    {
        _http = http;
        _config = config;
        _logger = logger;
    }

    public async Task<NightscoutReadingsResult> GetLatestAsync(int count = 6, CancellationToken ct = default)
    {
        count = Math.Clamp(count, 1, MaxLatestCount);
        return await FetchReadingsAsync($"/entries/sgv.json?count={count}", count, null, null, ct);
    }

    public async Task<NightscoutReadingsResult> GetHistoryAsync(string? start, string? end, int count = 288, CancellationToken ct = default)
    {
        count = Math.Clamp(count <= 0 ? 288 : count, 1, MaxHistoryCount);

        var parsedEnd = ParseToolDate(end) ?? DateTimeOffset.Now;
        var parsedStart = ParseToolDate(start) ?? parsedEnd.AddHours(-24);
        if (parsedStart > parsedEnd)
            (parsedStart, parsedEnd) = (parsedEnd, parsedStart);

        var startUtc = parsedStart.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
        var endUtc = parsedEnd.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);

        var query = string.Join("&", new[]
        {
            $"count={count}",
            $"find[dateString][$gte]={Uri.EscapeDataString(startUtc)}",
            $"find[dateString][$lte]={Uri.EscapeDataString(endUtc)}"
        });

        return await FetchReadingsAsync($"/entries/sgv.json?{query}", count, parsedStart, parsedEnd, ct);
    }

    public async Task<NightscoutStatusResult> GetStatusAsync(CancellationToken ct = default)
    {
        var result = new NightscoutStatusResult
        {
            Configured = IsConfigured(),
            FetchedAt = DateTimeOffset.Now,
            BaseUrl = _config.Nightscout.BaseUrl
        };

        if (!result.Configured)
        {
            result.Message = "Nightscout is not configured. Set Nightscout URL and API-SECRET in Settings.";
            return result;
        }

        try
        {
            using var req = BuildRequest("/status.json");
            using var res = await _http.SendAsync(req, ct);
            result.HttpStatus = (int)res.StatusCode;
            var body = await res.Content.ReadAsStringAsync(ct);
            result.Success = res.IsSuccessStatusCode;

            if (!res.IsSuccessStatusCode)
            {
                result.Message = $"Nightscout status request failed: HTTP {(int)res.StatusCode} {res.ReasonPhrase}.";
                result.BodyPreview = Trim(body, 1000);
                return result;
            }

            using var doc = JsonDocument.Parse(body);
            var root = doc.RootElement;
            result.Version = TryGetString(root, "version");
            result.ServerTime = TryGetString(root, "serverTime");
            result.ApiEnabled = TryGetBool(root, "apiEnabled");
            result.Message = "Nightscout status request succeeded.";
            result.BodyPreview = Trim(body, 1000);
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Nightscout status request failed");
            result.Message = $"Nightscout status request failed: {ex.Message}";
            return result;
        }
    }

    public string FormatReadingsForTool(NightscoutReadingsResult result)
    {
        if (!result.Configured)
            return result.Message;

        if (!result.Success)
            return $"{result.Message}\nSource: {result.SourceUrl}";

        if (result.Readings.Count == 0)
            return $"Nightscout returned no CGM readings.\nSource: {result.SourceUrl}";

        var lines = new List<string>
        {
            "Nightscout CGM readings (read-only; CGM data can lag or be inaccurate, do not infer insulin dosing or urgent medical decisions from this alone):",
            $"Fetched: {FormatDateTime(result.FetchedAt)}",
            $"Source: {result.SourceUrl}",
            $"Returned: {result.Readings.Count} reading(s)"
        };

        if (result.Start.HasValue || result.End.HasValue)
            lines.Add($"Window: {FormatNullableDateTime(result.Start)} to {FormatNullableDateTime(result.End)}");

        if (result.Summary != null)
        {
            lines.Add($"Summary: min {FormatGlucose((int?)result.Summary.MinSgv)}, max {FormatGlucose((int?)result.Summary.MaxSgv)}, average {FormatGlucose(result.Summary.AverageSgv)}");
        }

        lines.Add("");
        lines.Add("Readings:");
        foreach (var reading in result.Readings)
            lines.Add($"- {FormatReading(reading)}");

        return string.Join("\n", lines);
    }

    public string FormatStatusForTool(NightscoutStatusResult result)
    {
        if (!result.Configured)
            return result.Message;

        var lines = new List<string>
        {
            result.Message,
            $"Base URL: {result.BaseUrl}",
            $"Fetched: {FormatDateTime(result.FetchedAt)}"
        };

        if (result.HttpStatus.HasValue)
            lines.Add($"HTTP status: {result.HttpStatus}");
        if (!string.IsNullOrWhiteSpace(result.Version))
            lines.Add($"Nightscout version: {result.Version}");
        if (!string.IsNullOrWhiteSpace(result.ServerTime))
            lines.Add($"Server time: {result.ServerTime}");
        if (result.ApiEnabled.HasValue)
            lines.Add($"API enabled: {result.ApiEnabled}");
        if (!string.IsNullOrWhiteSpace(result.BodyPreview))
            lines.Add($"Body preview: {result.BodyPreview}");

        return string.Join("\n", lines);
    }

    private async Task<NightscoutReadingsResult> FetchReadingsAsync(
        string relativeUrl,
        int requestedCount,
        DateTimeOffset? start,
        DateTimeOffset? end,
        CancellationToken ct)
    {
        var result = new NightscoutReadingsResult
        {
            Configured = IsConfigured(),
            FetchedAt = DateTimeOffset.Now,
            RequestedCount = requestedCount,
            Start = start,
            End = end
        };

        if (!result.Configured)
        {
            result.Message = "Nightscout is not configured. Set Nightscout URL and API-SECRET in Settings.";
            return result;
        }

        try
        {
            using var req = BuildRequest(relativeUrl);
            result.SourceUrl = req.RequestUri?.ToString() ?? "";
            using var res = await _http.SendAsync(req, ct);
            result.HttpStatus = (int)res.StatusCode;
            var body = await res.Content.ReadAsStringAsync(ct);

            if (!res.IsSuccessStatusCode)
            {
                result.Message = $"Nightscout readings request failed: HTTP {(int)res.StatusCode} {res.ReasonPhrase}. {Trim(body, 700)}";
                return result;
            }

            var entries = JsonSerializer.Deserialize<List<NightscoutEntryDto>>(body, JsonOptions) ?? new();
            result.Readings = entries
                .Select(ToReading)
                .Where(r => r.Timestamp.HasValue && r.Sgv.HasValue)
                .OrderByDescending(r => r.Timestamp)
                .ToList();
            result.Success = true;
            result.Message = "Nightscout readings request succeeded.";
            result.Summary = BuildSummary(result.Readings);
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Nightscout readings request failed");
            result.Message = $"Nightscout readings request failed: {ex.Message}";
            return result;
        }
    }

    private HttpRequestMessage BuildRequest(string relativeUrl)
    {
        var apiBase = GetApiBaseUrl();
        var url = apiBase.TrimEnd('/') + relativeUrl;
        var req = new HttpRequestMessage(HttpMethod.Get, url);

        if (!string.IsNullOrWhiteSpace(_config.Nightscout.ApiSecret))
            req.Headers.TryAddWithoutValidation("API-SECRET", _config.Nightscout.ApiSecret.Trim());

        return req;
    }

    private bool IsConfigured()
    {
        return _config.Nightscout.Enabled
               && !string.IsNullOrWhiteSpace(_config.Nightscout.BaseUrl);
    }

    private string GetApiBaseUrl()
    {
        var baseUrl = _config.Nightscout.BaseUrl.Trim().TrimEnd('/');
        return baseUrl.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase)
            ? baseUrl
            : $"{baseUrl}/api/v1";
    }

    private static NightscoutReading ToReading(NightscoutEntryDto entry)
    {
        DateTimeOffset? timestamp = null;

        if (entry.Date.HasValue)
        {
            try
            {
                timestamp = DateTimeOffset.FromUnixTimeMilliseconds(entry.Date.Value).ToLocalTime();
            }
            catch
            {
                timestamp = null;
            }
        }

        if (!timestamp.HasValue && !string.IsNullOrWhiteSpace(entry.DateString)
            && DateTimeOffset.TryParse(entry.DateString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
        {
            timestamp = parsed.ToLocalTime();
        }

        return new NightscoutReading
        {
            Timestamp = timestamp,
            Sgv = entry.Sgv,
            Mmol = entry.Sgv.HasValue ? Math.Round(entry.Sgv.Value / 18.0, 1) : null,
            Direction = entry.Direction ?? "",
            Trend = entry.Trend,
            Device = entry.Device ?? "",
            Type = entry.Type ?? "",
            DateString = entry.DateString ?? ""
        };
    }

    private static NightscoutReadingsSummary? BuildSummary(IReadOnlyList<NightscoutReading> readings)
    {
        var values = readings
            .Where(r => r.Sgv.HasValue)
            .Select(r => r.Sgv!.Value)
            .ToList();

        if (values.Count == 0)
            return null;

        return new NightscoutReadingsSummary
        {
            MinSgv = values.Min(),
            MaxSgv = values.Max(),
            AverageSgv = values.Average()
        };
    }

    private static DateTimeOffset? ParseToolDate(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
            return null;

        return DateTimeOffset.TryParse(
            value,
            CultureInfo.InvariantCulture,
            DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeLocal,
            out var parsed)
            ? parsed
            : null;
    }

    private static string FormatReading(NightscoutReading reading)
    {
        var time = reading.Timestamp.HasValue ? FormatDateTime(reading.Timestamp.Value) : "(no timestamp)";
        var age = reading.Timestamp.HasValue
            ? $", age {FormatAge(DateTimeOffset.Now - reading.Timestamp.Value)}"
            : "";
        var direction = string.IsNullOrWhiteSpace(reading.Direction)
            ? ""
            : $", {reading.Direction} {DirectionArrow(reading.Direction)}".TrimEnd();
        var device = string.IsNullOrWhiteSpace(reading.Device) ? "" : $", device {reading.Device}";
        return $"{time}: {FormatGlucose(reading.Sgv)}{direction}{age}{device}";
    }

    private static string FormatGlucose(int? sgv)
    {
        if (!sgv.HasValue)
            return "unknown glucose";

        return $"{Math.Round(sgv.Value / 18.0, 1):0.0} mmol/L ({sgv.Value} mg/dL)";
    }

    private static string FormatGlucose(double sgv)
    {
        return $"{Math.Round(sgv / 18.0, 1):0.0} mmol/L ({sgv:0} mg/dL)";
    }

    private static string FormatDateTime(DateTimeOffset value)
    {
        return value.ToLocalTime().ToString("dddd dd/MM/yyyy hh:mm:ss tt zzz", CultureInfo.InvariantCulture);
    }

    private static string FormatNullableDateTime(DateTimeOffset? value)
    {
        return value.HasValue ? FormatDateTime(value.Value) : "(not specified)";
    }

    private static string FormatAge(TimeSpan age)
    {
        if (age.TotalMinutes < 1)
            return "less than 1 min";
        if (age.TotalHours < 1)
            return $"{Math.Round(age.TotalMinutes):0} min";
        return $"{Math.Round(age.TotalHours, 1):0.0} h";
    }

    private static string DirectionArrow(string direction) => direction switch
    {
        "DoubleUp" => "↑↑",
        "SingleUp" => "↑",
        "FortyFiveUp" => "↗",
        "Flat" => "→",
        "FortyFiveDown" => "↘",
        "SingleDown" => "↓",
        "DoubleDown" => "↓↓",
        _ => ""
    };

    private static string? TryGetString(JsonElement root, string name)
    {
        return root.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String
            ? prop.GetString()
            : null;
    }

    private static bool? TryGetBool(JsonElement root, string name)
    {
        return root.TryGetProperty(name, out var prop) && (prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
            ? prop.GetBoolean()
            : null;
    }

    private static string Trim(string text, int max)
    {
        if (string.IsNullOrWhiteSpace(text))
            return "";
        text = text.Trim();
        return text.Length <= max ? text : text[..max] + "...";
    }

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true
    };

    private sealed class NightscoutEntryDto
    {
        [JsonPropertyName("sgv")] public int? Sgv { get; set; }
        [JsonPropertyName("date")] public long? Date { get; set; }
        [JsonPropertyName("dateString")] public string? DateString { get; set; }
        [JsonPropertyName("direction")] public string? Direction { get; set; }
        [JsonPropertyName("trend")] public int? Trend { get; set; }
        [JsonPropertyName("device")] public string? Device { get; set; }
        [JsonPropertyName("type")] public string? Type { get; set; }
    }
}

public class NightscoutReadingsResult
{
    public bool Configured { get; set; }
    public bool Success { get; set; }
    public string Message { get; set; } = "";
    public string SourceUrl { get; set; } = "";
    public int? HttpStatus { get; set; }
    public int RequestedCount { get; set; }
    public DateTimeOffset FetchedAt { get; set; }
    public DateTimeOffset? Start { get; set; }
    public DateTimeOffset? End { get; set; }
    public IReadOnlyList<NightscoutReading> Readings { get; set; } = [];
    public NightscoutReadingsSummary? Summary { get; set; }
}

public class NightscoutStatusResult
{
    public bool Configured { get; set; }
    public bool Success { get; set; }
    public string Message { get; set; } = "";
    public string BaseUrl { get; set; } = "";
    public int? HttpStatus { get; set; }
    public DateTimeOffset FetchedAt { get; set; }
    public string? Version { get; set; }
    public string? ServerTime { get; set; }
    public bool? ApiEnabled { get; set; }
    public string BodyPreview { get; set; } = "";
}

public class NightscoutReading
{
    public DateTimeOffset? Timestamp { get; set; }
    public int? Sgv { get; set; }
    public double? Mmol { get; set; }
    public string Direction { get; set; } = "";
    public int? Trend { get; set; }
    public string Device { get; set; } = "";
    public string Type { get; set; } = "";
    public string DateString { get; set; } = "";
}

public class NightscoutReadingsSummary
{
    public int MinSgv { get; set; }
    public int MaxSgv { get; set; }
    public double AverageSgv { get; set; }
}
Offline