📄 src/Graph/GraphService.cs
using System.Text;
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.Kiota.Abstractions.Authentication;
using NotesApp.Models;

namespace NotesApp.Graph;

public class GraphService
{
    private readonly GraphServiceClient _client;
    private string? _driveId;

    public GraphService(Activity activity)
    {
        _client = new GraphServiceClient(
            new BaseBearerTokenAuthenticationProvider(new GraphAccessTokenProvider(activity))
        );
    }

    // Me.Drive only exposes the drive resource itself in this SDK; navigating into
    // root/items/children requires going through Drives[driveId] instead.
    private async Task<string> GetDriveIdAsync(CancellationToken ct)
    {
        if (_driveId != null)
            return _driveId;

        var drive = await _client.Me.Drive.GetAsync(cancellationToken: ct);
        _driveId =
            drive?.Id
            ?? throw new InvalidOperationException("Could not resolve OneDrive drive id.");
        return _driveId;
    }

    public async Task<List<DriveFolderItem>> ListChildFoldersAsync(
        string? folderId,
        CancellationToken ct = default
    )
    {
        var driveId = await GetDriveIdAsync(ct);
        var itemId = folderId ?? "root"; // Graph accepts the literal id "root" as an alias.

        var page = await _client
            .Drives[driveId]
            .Items[itemId]
            .Children.GetAsync(cfg => cfg.QueryParameters.Select = ["id", "name", "folder"], ct);

        var results = new List<DriveFolderItem>();
        if (page is null)
            return results;

        var iterator = PageIterator<DriveItem, DriveItemCollectionResponse>.CreatePageIterator(
            _client,
            page,
            item =>
            {
                if (item.Folder != null && item.Id != null && item.Name != null)
                    results.Add(new DriveFolderItem(item.Id, item.Name));
                return true;
            }
        );
        await iterator.IterateAsync(ct);

        return results.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase).ToList();
    }

    public async Task<List<DriveNoteMetadata>> ListMarkdownFilesAsync(
        string folderId,
        CancellationToken ct = default
    )
    {
        var driveId = await GetDriveIdAsync(ct);

        var page = await _client
            .Drives[driveId]
            .Items[folderId]
            .Children.GetAsync(
                cfg =>
                {
                    cfg.QueryParameters.Select =
                    [
                        "id",
                        "name",
                        "eTag",
                        "lastModifiedDateTime",
                        "size",
                        "file",
                    ];
                    cfg.QueryParameters.Top = 200;
                },
                ct
            );

        var results = new List<DriveNoteMetadata>();
        if (page is null)
            return results;

        // With 1000+ notes this always spans multiple pages; PageIterator follows
        // @odata.nextLink automatically until the whole folder has been walked.
        var iterator = PageIterator<DriveItem, DriveItemCollectionResponse>.CreatePageIterator(
            _client,
            page,
            item =>
            {
                if (
                    item.File != null
                    && item.Id != null
                    && item.Name != null
                    && item.Name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
                )
                {
                    results.Add(
                        new DriveNoteMetadata(
                            item.Id,
                            item.Name,
                            item.ETag ?? "",
                            item.LastModifiedDateTime,
                            item.Size
                        )
                    );
                }
                return true;
            }
        );
        await iterator.IterateAsync(ct);

        return results.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase).ToList();
    }

    private const int PreviewByteRange = 1024;
    private const int BatchSize = 20; // Graph's JSON $batch endpoint allows at most 20 sub-requests.

    // Fetches only the first ~1KB of each note's content so the list preview doesn't require
    // downloading full note bodies, and batches requests so a first-time sync of a large
    // library doesn't take one HTTP round trip per note.
    public async Task<Dictionary<string, string>> GetPreviewsAsync(
        IReadOnlyList<string> itemIds,
        CancellationToken ct = default
    )
    {
        var previews = new Dictionary<string, string>();
        if (itemIds.Count == 0)
            return previews;

        var driveId = await GetDriveIdAsync(ct);

        foreach (var chunk in itemIds.Chunk(BatchSize))
        {
            var batch = new BatchRequestContentCollection(_client);
            var requestIds = new List<(string RequestId, string ItemId)>();

            foreach (var itemId in chunk)
            {
                var requestInfo = _client
                    .Drives[driveId]
                    .Items[itemId]
                    .Content.ToGetRequestInformation();
                requestInfo.Headers.Add("Range", [$"bytes=0-{PreviewByteRange - 1}"]);
                var requestId = await batch.AddBatchRequestStepAsync(requestInfo, itemId);
                requestIds.Add((requestId, itemId));
            }

            var response = await _client.Batch.PostAsync(batch, ct);

            foreach (var (requestId, itemId) in requestIds)
            {
                try
                {
                    var stream = await response.GetResponseStreamByIdAsync(requestId);
                    using var reader = new StreamReader(stream, Encoding.UTF8);
                    var raw = await reader.ReadToEndAsync(ct);
                    previews[itemId] = TrimPreview(raw);
                }
                catch
                {
                    // Leave this item out; its ETag will still differ from the cache next
                    // refresh, so it's retried automatically rather than silently dropped.
                }
            }
        }

        return previews;
    }

    private static string TrimPreview(string raw)
    {
        // A byte range can cut mid-line or mid-character; drop the trailing partial line.
        var lastNewline = raw.LastIndexOf('\n');
        var trimmed = lastNewline > 0 ? raw[..lastNewline] : raw;
        const int maxLength = 300;
        return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
    }

    public async Task<string> GetContentAsync(string itemId, CancellationToken ct = default)
    {
        var driveId = await GetDriveIdAsync(ct);

        var stream = await _client
            .Drives[driveId]
            .Items[itemId]
            .Content.GetAsync(cancellationToken: ct);
        if (stream is null)
            return "";

        using var reader = new StreamReader(stream, Encoding.UTF8);
        return await reader.ReadToEndAsync(ct);
    }

    public async Task<(string ETag, DateTimeOffset? LastModifiedUtc, long? Size)> PutContentAsync(
        string itemId,
        string content,
        CancellationToken ct = default
    )
    {
        var driveId = await GetDriveIdAsync(ct);
        using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
        var updated = await _client
            .Drives[driveId]
            .Items[itemId]
            .Content.PutAsync(stream, cfg => cfg.Headers.Add("Content-Type", ["text/plain"]), ct);

        return (updated?.ETag ?? "", updated?.LastModifiedDateTime, updated?.Size);
    }

    public async Task<NoteCacheEntry> CreateNoteAsync(
        string folderId,
        string fileName,
        string content,
        string preview,
        CancellationToken ct = default
    )
    {
        var driveId = await GetDriveIdAsync(ct);
        var created = await _client
            .Drives[driveId]
            .Items[folderId]
            .Children.PostAsync(
                new DriveItem { Name = fileName, File = new FileObject() },
                cancellationToken: ct
            );
        var newId =
            created?.Id
            ?? throw new InvalidOperationException("Create failed: no item id returned.");

        var (eTag, lastModified, size) = await PutContentAsync(newId, content, ct);
        return new NoteCacheEntry(newId, fileName, eTag, lastModified, preview, size);
    }
}