📄 src/Storage/NoteContentCache.cs
namespace NotesApp.Storage;

// Full note bodies fetched during this app process, keyed by note id. Storing the in-flight
// Task<string> itself (not just the eventual result) lets a prefetch and an on-demand open of
// the same note share one Graph request instead of racing two - whichever asks first starts
// the fetch, and both awaiters land on the same task. In-memory only, unlike NoteCacheStore's
// list metadata: full bodies for 1000+ notes aren't worth persisting to disk, and staleness
// only matters for the current process's lifetime.
public static class NoteContentCache
{
    private static readonly Dictionary<string, Task<string>> Cache = new();

    // Raised whenever a fetch started via GetOrStart completes successfully - whether that
    // fetch was a background prefetch or an on-demand open. Listeners (the note list) use this
    // to refresh a row's preview with the real content instead of the byte-range-truncated one
    // from GetPreviewsAsync. Not raised by Set(), since a save's caller already knows the fresh
    // preview and updates the list itself through the normal result-passback path.
    public static event Action<string, string>? ContentFetched;

    public static bool TryGet(string noteId, out Task<string> contentTask) =>
        Cache.TryGetValue(noteId, out contentTask!);

    // Returns the existing fetch for this note if one's already running or done; otherwise
    // starts one via startFetch and caches it. Never starts a second fetch for the same note.
    public static Task<string> GetOrStart(string noteId, Func<Task<string>> startFetch)
    {
        if (Cache.TryGetValue(noteId, out var existing))
            return existing;

        var task = Track(noteId, startFetch());
        Cache[noteId] = task;
        return task;
    }

    public static void Set(string noteId, string content) =>
        Cache[noteId] = Task.FromResult(content);

    public static void Remove(string noteId) => Cache.Remove(noteId);

    private static async Task<string> Track(string noteId, Task<string> fetch)
    {
        try
        {
            var content = await fetch;
            ContentFetched?.Invoke(noteId, content);
            return content;
        }
        catch
        {
            // A cached failed task would otherwise wedge that note - every future open/prefetch
            // would rethrow the same stale error instead of retrying.
            Cache.Remove(noteId);
            throw;
        }
    }
}