using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Flurl.Http;
using Kavita.API.Services;
using Kavita.API.Services.SignalR;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Extensions;
using Kavita.Common.Helpers;
using Kavita.Models.DTOs.SignalR;
using Kavita.Models.DTOs.Update;
using Kavita.Services.Extensions;
using Markdig;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
using Markdown = Markdig.Markdown;

namespace Kavita.Services;

internal class GithubReleaseMetadata
{
    /// <summary>
    /// Name of the Tag
    /// <example>v0.4.3</example>
    /// </summary>
    // ReSharper disable once InconsistentNaming
    public required string Tag_Name { get; init; }
    /// <summary>
    /// Name of the Release
    /// </summary>
    public required string Name { get; init; }
    /// <summary>
    /// Body of the Release
    /// </summary>
    public required string Body { get; init; }
    /// <summary>
    /// Url of the release on GitHub
    /// </summary>
    // ReSharper disable once InconsistentNaming
    public required string Html_Url { get; init; }
    /// <summary>
    /// Date Release was Published
    /// </summary>
    // ReSharper disable once InconsistentNaming
    public required string Published_At { get; init; }
}


public partial class VersionUpdaterService : IVersionUpdaterService
{
    private readonly ILogger<VersionUpdaterService> _logger;
    private readonly IEventHub _eventHub;
    private readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder().UseGithub().Build();

#pragma warning disable S1075
    private const string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest";
    private const string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases";
    private const string GithubPullsUrl = "https://api.github.com/repos/Kareadita/Kavita/pulls/";
    private const string GithubBranchCommitsUrl = "https://api.github.com/repos/Kareadita/Kavita/commits?sha=develop";
#pragma warning restore S1075

    [GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)]
    private static partial Regex BlogPartRegex();
    private readonly string _cacheFilePath;
    /// <summary>
    /// The latest release cache
    /// </summary>
    private readonly string _cacheLatestReleaseFilePath;
    private readonly string _cacheNightlyInfoFilePath;
    private readonly string _cacheCommitsFilePath;
    private readonly string _cachePrInfoDirectory;

    private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
    private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

    public VersionUpdaterService(ILogger<VersionUpdaterService> logger, IEventHub eventHub, IDirectoryService directoryService)
    {
        _logger = logger;
        _eventHub = eventHub;

        _cacheFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_releases_cache.json");
        _cacheLatestReleaseFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_latest_release_cache.json");
        _cacheNightlyInfoFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_nightly_cache.json");
        _cacheCommitsFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_commits_cache.json");

        _cachePrInfoDirectory = Path.Combine(directoryService.LongTermCacheDirectory, "pr_cache");
        directoryService.ExistOrCreate(_cachePrInfoDirectory);

        FlurlConfiguration.ConfigureClientForUrl(GithubLatestReleasesUrl);
        FlurlConfiguration.ConfigureClientForUrl(GithubAllReleasesUrl);
    }

    /// <summary>
    /// Fetches the latest (stable) release from GitHub. Does not do any extra nightly release parsing.
    /// </summary>
    /// <param name="ct"></param>
    /// <returns>Latest update</returns>
    public async Task<UpdateNotificationDto?> CheckForUpdate(CancellationToken ct = default)
    {
        // Attempt to fetch from cache
        var cachedRelease = await TryGetCachedLatestRelease();
        if (cachedRelease != null)
        {
            return cachedRelease;
        }

        var update = await GetGithubRelease();
        var dto = CreateDto(update);

        if (dto != null)
        {
            await CacheLatestReleaseAsync(dto);
        }

        return dto;
    }

    /// <summary>
    /// Will add any extra (nightly) updates from the latest stable. Does not back-fill anything prior to the latest stable.
    /// </summary>
    /// <param name="dtos"></param>
    private async Task EnrichWithNightlyInfo(List<UpdateNotificationDto> dtos)
    {
        var dto = dtos[0]; // Latest version
        try
        {
            var currentVersion = new Version(dto.CurrentVersion);
            var nightlyReleases = await GetNightlyReleases(currentVersion, Version.Parse(dto.UpdateVersion));

            if (nightlyReleases.Count == 0) return;

            // Create new DTOs for each nightly release and insert them at the beginning of the list
            var nightlyDtos = new List<UpdateNotificationDto>();
            foreach (var nightly in nightlyReleases)
            {
                var prInfo = await FetchPullRequestInfo(nightly.PrNumber);
                if (prInfo == null) continue;

                var sections = ParseReleaseBody(prInfo.Body);
                var blogPart = ExtractBlogPart(prInfo.Body);

                var nightlyDto = new UpdateNotificationDto
                {
                    // default: I should pass Title to the FE so that Nightly Release can be localized
                    UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}",
                    UpdateVersion = nightly.Version,
                    CurrentVersion = dto.CurrentVersion,
                    UpdateUrl = prInfo.Html_Url,
                    PublishDate = prInfo.Merged_At,
                    IsDocker = true, // Nightlies are always Docker Only
                    IsReleaseEqual = IsVersionEqualToBuildVersion(Version.Parse(nightly.Version)),
                    IsReleaseNewer = true, // Since we already filtered these in GetNightlyReleases
                    IsPrerelease = true, // All Nightlies are considered prerelease
                    Added = sections.TryGetValue("Added", out var added) ? added : [],
                    Changed = sections.TryGetValue("Changed", out var changed) ? changed : [],
                    Fixed = sections.TryGetValue("Fixed", out var bugfixes) ? bugfixes : [],
                    Removed = sections.TryGetValue("Removed", out var removed) ? removed : [],
                    Theme = sections.TryGetValue("Theme", out var theme) ? theme : [],
                    Developer = sections.TryGetValue("Developer", out var developer) ? developer : [],
                    KnownIssues = sections.TryGetValue("Known Issues", out var knownIssues) ? knownIssues : [],
                    Api = sections.TryGetValue("Api", out var api) ? api : [],
                    FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [],
                    BlogPart = Markdown.ToHtml(blogPart.Trim(), _markdownPipeline),
                    UpdateBody = Markdown.ToHtml(prInfo.Body.Trim(), _markdownPipeline)
                };

                nightlyDtos.Add(nightlyDto);
            }

            // Insert nightly releases at the beginning of the list
            var sortedNightlyDtos = nightlyDtos.OrderByDescending(x => x.PublishDate).ToList();
            dtos.InsertRange(0, sortedNightlyDtos);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to enrich nightly release information");
        }
    }


    private async Task<PullRequestInfo?> FetchPullRequestInfo(int prNumber)
    {
        var cacheFile = Path.Combine(_cachePrInfoDirectory, $"pr_{prNumber}.json");

        if (File.Exists(cacheFile))
        {
            var fileInfo = new FileInfo(cacheFile);
            if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= TimeSpan.FromDays(7)) // PRs don't change
            {
                var cachedData = await File.ReadAllTextAsync(cacheFile);
                return JsonSerializer.Deserialize<PullRequestInfo>(cachedData);
            }
        }

        try
        {
            var prInfo = await $"{GithubPullsUrl}{prNumber}"
                .WithGithubHeaders()
                .GetJsonAsync<PullRequestInfo>();

            // Cache the result
            var tempPath = $"{cacheFile}.{Guid.NewGuid():N}.tmp";
            var json = JsonSerializer.Serialize(prInfo, JsonOptions);
            await File.WriteAllTextAsync(tempPath, json);
            File.Move(tempPath, cacheFile, overwrite: true);

            return prInfo;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to fetch PR information for #{PrNumber}", prNumber);
            return null;
        }
    }

    private async Task<List<NightlyInfo>> GetNightlyReleases(Version currentVersion, Version latestStableVersion)
    {
        var cached = await TryGetCachedNightlyInfo();
        if (cached != null)
        {
            return cached.Where(n => Version.Parse(n.Version) > latestStableVersion).ToList();
        }

        try
        {
            var nightlyReleases = new List<NightlyInfo>();

            var commits = await GithubBranchCommitsUrl
                .WithGithubHeaders()
                .GetJsonAsync<IList<CommitInfo>>();

            var commitList = commits.ToList();
            var foundLastStable = false;

            for (var i = 0; i < commitList.Count - 1; i++)
            {
                var commit = commitList[i];
                var message = commit.Commit.Message.Split('\n')[0]; // Take first line only

                // Skip [skip ci] commits
                if (message.Contains("[skip ci]")) continue;

                // Check if this is a stable release
                if (message.StartsWith('v'))
                {
                    var stableMatch = Regex.Match(message, @"v(\d+\.\d+\.\d+\.\d+)");
                    if (stableMatch.Success)
                    {
                        var stableVersion = new Version(stableMatch.Groups[1].Value);
                        // If we find a stable version lower than current, we've gone too far back
                        if (stableVersion <= currentVersion)
                        {
                            foundLastStable = true;
                            break;
                        }
                    }
                    continue;
                }

                // Look for version bumps that follow PRs
                if (!foundLastStable && message == "Bump versions by dotnet-bump-version.")
                {
                    // Get the PR commit that triggered this version bump
                    if (i + 1 < commitList.Count)
                    {
                        var prCommit = commitList[i + 1];
                        var prMessage = prCommit.Commit.Message.Split('\n')[0];

                        // Extract PR number using improved regex
                        var prMatch = Regex.Match(prMessage, @"(?:^|\s)\(#(\d+)\)|\s#(\d+)");
                        if (!prMatch.Success) continue;

                        var prNumber = int.Parse(prMatch.Groups[1].Value != "" ?
                            prMatch.Groups[1].Value : prMatch.Groups[2].Value);

                        // Get the version from AssemblyInfo.cs in this commit
                        var version = await GetVersionFromCommit(commit.Sha);
                        if (version == null) continue;

                        // Parse version and compare with current version
                        if (Version.TryParse(version, out var parsedVersion) &&
                            parsedVersion > latestStableVersion)
                        {
                            nightlyReleases.Add(new NightlyInfo
                            {
                                Version = version,
                                PrNumber = prNumber,
                                Date = DateTime.Parse(commit.Commit.Author.Date, CultureInfo.InvariantCulture)
                            });
                        }
                    }
                }
            }

            await CacheNightlyInfoAsync(nightlyReleases);

            return nightlyReleases.OrderByDescending(x => x.Date).ToList();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to get nightly releases");
            return [];
        }
    }

    public async Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0, CancellationToken ct = default)
    {
        // Attempt to fetch from cache
        var cachedReleases = await TryGetCachedReleases();
        // If there is a cached release and the current version is within it, use it, otherwise regenerate
        if (cachedReleases != null && cachedReleases.Any(r => IsVersionEqual(r.UpdateVersion, BuildInfo.Version.ToString())))
        {
            if (count > 0)
            {
                // NOTE: We may want to allow the admin to clear Github cache
                return cachedReleases.Take(count).ToList();
            }

            return cachedReleases;
        }

        var updates = await GetGithubReleases();
        var query = updates.Select(CreateDto)
            .Where(d => d != null)
            .OrderByDescending(d => d!.PublishDate)
            .Select(d => d!);

        var updateDtos = query.ToList();

        // Sometimes a release can be 0.8.5.0 on disk, but 0.8.5 from Github
        var versionParts = updateDtos[0].UpdateVersion.Split('.');
        if (versionParts.Length < 4)
        {
            updateDtos[0].UpdateVersion += ".0"; // Append missing parts
        }

        // If we're on a nightly build, enrich the information
        if (updateDtos.Count != 0) // && BuildInfo.Version > new Version(updateDtos[0].UpdateVersion)
        {
            await EnrichWithNightlyInfo(updateDtos);
        }

        // Find the latest dto
        var latestRelease = updateDtos[0]!;
        var updateVersion = new Version(latestRelease.UpdateVersion);
        var isNightly = BuildInfo.Version > new Version(latestRelease.UpdateVersion);

        // isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0
        if (IsVersionEqualToBuildVersion(updateVersion))
        {
            isNightly = false;
        }


        latestRelease.IsOnNightlyInRelease = isNightly;

        // Cache the fetched data
        if (updateDtos.Count > 0)
        {
            await CacheReleasesAsync(updateDtos);
        }

        if (count > 0)
        {
            return updateDtos.Take(count).ToList();
        }

        return updateDtos;
    }

    /// <summary>
    /// Compares 2 versions and ensures that the minor is always there
    /// </summary>
    /// <param name="v1"></param>
    /// <param name="v2"></param>
    /// <returns></returns>
    private static bool IsVersionEqual(string v1, string v2)
    {
        var versionParts = v1.Split('.');
        if (versionParts.Length < 4)
        {
            v1 += ".0"; // Append missing parts
        }

        versionParts = v2.Split('.');
        if (versionParts.Length < 4)
        {
            v2 += ".0"; // Append missing parts
        }

        return string.Equals(v2, v2, StringComparison.OrdinalIgnoreCase);
    }

    private async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases()
    {
        if (!File.Exists(_cacheFilePath)) return null;

        var fileInfo = new FileInfo(_cacheFilePath);
        if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
        {
            var cachedData = await File.ReadAllTextAsync(_cacheFilePath);
            return JsonSerializer.Deserialize<IList<UpdateNotificationDto>>(cachedData);
        }

        return null;
    }

    private async Task<UpdateNotificationDto?> TryGetCachedLatestRelease()
    {
        if (!File.Exists(_cacheLatestReleaseFilePath)) return null;

        var fileInfo = new FileInfo(_cacheLatestReleaseFilePath);
        if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
        {
            var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath);
            return JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
        }

        return null;
    }

    private async Task CacheReleasesAsync(IList<UpdateNotificationDto> updates)
    {
        try
        {
            var tempPath = Path.Combine(
                Path.GetDirectoryName(_cacheFilePath)!,
                $"{Path.GetFileName(_cacheFilePath)}.{Guid.NewGuid():N}.tmp");

            var json = JsonSerializer.Serialize(updates, JsonOptions);
            await File.WriteAllTextAsync(tempPath, json);

            // Atomic replace - handles file in use scenarios
            File.Move(tempPath, _cacheFilePath, overwrite: true);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to cache releases");
        }
    }

    private async Task CacheLatestReleaseAsync(UpdateNotificationDto update)
    {
        try
        {
            var tempPath = Path.Combine(
                Path.GetDirectoryName(_cacheLatestReleaseFilePath)!,
                $"{Path.GetFileName(_cacheLatestReleaseFilePath)}.{Guid.NewGuid():N}.tmp");

            var json = JsonSerializer.Serialize(update, JsonOptions);
            await File.WriteAllTextAsync(tempPath, json);

            File.Move(tempPath, _cacheLatestReleaseFilePath, overwrite: true);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to cache latest release");
        }
    }



    private static bool IsVersionEqualToBuildVersion(Version updateVersion)
    {
        return updateVersion == BuildInfo.Version || (updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 &&
                                                      BuildInfo.Version.CompareWithoutRevision(updateVersion));
    }


    /// <summary>
    /// Returns the number of releases ahead of this install version. If this install version is on a nightly,
    /// then include nightly releases, otherwise only count Stable releases.
    /// </summary>
    /// <param name="stableOnly">Only count Stable releases </param>
    /// <param name="ct"></param>
    /// <returns></returns>
    public async Task<int> GetNumberOfReleasesBehind(bool stableOnly = false, CancellationToken ct = default)
    {
        var updates = await GetAllReleases(ct: ct);

        // If the user is on nightly, then we need to handle releases behind differently
        if (!stableOnly && (updates[0].IsPrerelease || updates[0].IsOnNightlyInRelease))
        {
            return updates.Count(u => u.IsReleaseNewer);
        }

        return updates
            .Where(update => !update.IsPrerelease)
            .Count(u => u.IsReleaseNewer);
    }

    /// <summary>
    /// Clears the Github cache
    /// </summary>
    /// <param name="ct"></param>
    public void BustGithubCache(CancellationToken ct = default)
    {
        try
        {
            File.Delete(_cacheFilePath);
            File.Delete(_cacheLatestReleaseFilePath);
            File.Delete(_cacheNightlyInfoFilePath);
            File.Delete(_cacheCommitsFilePath);

            if (!Directory.Exists(_cachePrInfoDirectory)) return;

            Directory.Delete(_cachePrInfoDirectory, recursive: true);
            Directory.CreateDirectory(_cachePrInfoDirectory);
        } catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to clear Github cache");
        }
    }

    private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
    {
        if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
        var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty));
        var currentVersion = BuildInfo.Version.ToString(4);


        var bodyHtml = Markdown.ToHtml(update.Body.Trim(), _markdownPipeline);
        var parsedSections = ParseReleaseBody(update.Body);
        var blogPart = Markdown.ToHtml(ExtractBlogPart(update.Body).Trim(), _markdownPipeline);

        return new UpdateNotificationDto()
        {
            CurrentVersion = currentVersion,
            UpdateVersion = updateVersion.ToString(),
            UpdateBody = bodyHtml,
            UpdateTitle = update.Name,
            UpdateUrl = update.Html_Url,
            IsDocker = OsInfo.IsDocker,
            PublishDate = update.Published_At,
            IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion),
            IsReleaseNewer = BuildInfo.Version < updateVersion,
            IsPrerelease = false,

            Added = parsedSections.TryGetValue("Added", out var added) ? added : [],
            Removed = parsedSections.TryGetValue("Removed", out var removed) ? removed : [],
            Changed = parsedSections.TryGetValue("Changed", out var changed) ? changed : [],
            Fixed = parsedSections.TryGetValue("Fixed", out var fixes) ? fixes : [],
            Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [],
            Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [],
            KnownIssues = parsedSections.TryGetValue("Known Issues", out var knownIssues) ? knownIssues : [],
            Api = parsedSections.TryGetValue("Api", out var api) ? api : [],
            FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [],
            BlogPart = blogPart
        };
    }


    public async Task PushUpdate(UpdateNotificationDto update, CancellationToken ct = default)
    {
        if (update == null) return;

        var updateVersion = new Version(update.UpdateVersion);

        if (BuildInfo.Version < updateVersion)
        {
            _logger.LogWarning("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion);
            await _eventHub.SendMessageAsync(MessageFactory.UpdateAvailable, MessageFactory.UpdateVersionEvent(update),
                true, ct);
        }
    }

    private async Task<string?> GetVersionFromCommit(string commitSha)
    {
        try
        {
            // Use the raw GitHub URL format for the csproj file
            var content = await $"https://raw.githubusercontent.com/Kareadita/Kavita/{commitSha}/Kavita.Common/Kavita.Common.csproj"
                .WithHeader(HeaderNames.UserAgent, "Kavita")
                .GetStringAsync();

            var versionMatch = Regex.Match(content, @"<AssemblyVersion>([0-9\.]+)</AssemblyVersion>");
            return versionMatch.Success ? versionMatch.Groups[1].Value : null;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to get version from commit {Sha}: {Message}", commitSha, ex.Message);
            return null;
        }
    }



    private static async Task<GithubReleaseMetadata> GetGithubRelease()
    {
        var update = await GithubLatestReleasesUrl
            .WithHeader(HeaderNames.Accept, "application/json")
            .WithHeader(HeaderNames.UserAgent, "Kavita")
            .GetJsonAsync<GithubReleaseMetadata>();

        return update;
    }

    private static async Task<IList<GithubReleaseMetadata>> GetGithubReleases()
    {
        var update = await GithubAllReleasesUrl
            .WithHeader(HeaderNames.Accept, "application/json")
            .WithHeader(HeaderNames.UserAgent, "Kavita")
            .GetJsonAsync<IList<GithubReleaseMetadata>>();

        return update;
    }

    private static string ExtractBlogPart(string body)
    {
        if (body.StartsWith('#')) return string.Empty;
        var match = BlogPartRegex().Match(body);
        return match.Success ? match.Groups[1].Value.Trim() : body.Trim();
    }

    private static Dictionary<string, List<string>> ParseReleaseBody(string body)
    {
        var sections = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
        var lines = body.Split('\n');
        string? currentSection = null;

        foreach (var line in lines)
        {
            var trimmedLine = line.Trim();

            // Check for section headers (case-insensitive)
            if (trimmedLine.StartsWith('#'))
            {
                currentSection = trimmedLine.TrimStart('#').Trim();
                sections[currentSection] = [];
                continue;
            }

            // Parse items under a section
            if (currentSection != null &&
                trimmedLine.StartsWith("- ") &&
                !string.IsNullOrWhiteSpace(trimmedLine))
            {
                // Remove "Fixed:", "Added:" etc. if present
                var cleanedItem = CleanSectionItem(trimmedLine);

                // Some sections like API/Developer/Removed don't have the title repeated, so we need to check for an additional cleaning
                if (cleanedItem.StartsWith("- "))
                {
                    cleanedItem =  trimmedLine.Substring(2);
                }

                // Only add non-empty items
                if (!string.IsNullOrWhiteSpace(cleanedItem))
                {
                    sections[currentSection].Add(cleanedItem);
                }
            }
        }

        return sections;
    }

    private static string CleanSectionItem(string item)
    {
        // Remove everything up to and including the first ":"
        var colonIndex = item.IndexOf(':');
        if (colonIndex != -1)
        {
            item = item.Substring(colonIndex + 1).Trim();
        }

        return item;
    }

    private async Task<List<NightlyInfo>?> TryGetCachedNightlyInfo()
    {
        if (!File.Exists(_cacheNightlyInfoFilePath)) return null;

        var fileInfo = new FileInfo(_cacheNightlyInfoFilePath);
        if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
        {
            var cachedData = await File.ReadAllTextAsync(_cacheNightlyInfoFilePath);
            return JsonSerializer.Deserialize<List<NightlyInfo>>(cachedData);
        }

        return null;
    }

    private async Task CacheNightlyInfoAsync(List<NightlyInfo> nightlyInfo)
    {
        try
        {
            var tempPath = Path.Combine(
                Path.GetDirectoryName(_cacheNightlyInfoFilePath)!,
                $"{Path.GetFileName(_cacheNightlyInfoFilePath)}.{Guid.NewGuid():N}.tmp");

            var json = JsonSerializer.Serialize(nightlyInfo, JsonOptions);
            await File.WriteAllTextAsync(tempPath, json);

            File.Move(tempPath, _cacheNightlyInfoFilePath, overwrite: true);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to cache nightly info");
        }
    }

    private sealed class PullRequestInfo
    {
        public required string Title { get; init; }
        public required string Body { get; init; }
        public required string Html_Url { get; init; }
        public required string Merged_At { get; init; }
        public required int Number { get; init; }
    }

    private sealed class CommitInfo
    {
        public required string Sha { get; init; }
        public required CommitDetail Commit { get; init; }
        public required string Html_Url { get; init; }
    }

    private sealed class CommitDetail
    {
        public required string Message { get; init; }
        public required CommitAuthor Author { get; init; }
    }

    private sealed class CommitAuthor
    {
        public required string Date { get; init; }
    }

    private sealed class NightlyInfo
    {
        public required string Version { get; init; }
        public required int PrNumber { get; init; }
        public required DateTime Date { get; init; }
    }
}
