📄 MatDenDagen/Services/ExportService.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using MatDenDagen.Infrastructure.Storage.BlobStorage;
using MatDenDagen.Infrastructure.Storage.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace MatDenDagen.Services;

public sealed class ExportService(
    ILogger<ExportService> logger,
    QuestionnaireContext questionnaireContext,
    BlobStorageService blobStorage
)
{
    public async Task ExportAllSubmissions(Stream outputStream, CancellationToken cancellationToken)
    {
        await using var archive = await ZipArchive.CreateAsync(
            outputStream,
            ZipArchiveMode.Create,
            leaveOpen: true,
            Encoding.UTF8,
            cancellationToken
        );

        var questions = await questionnaireContext.Questions.ToDictionaryAsync(
            q => q.Id,
            q => q.Text,
            cancellationToken
        );

        var submissions = await questionnaireContext
            .Submissions.Include(s => s.Answers)
            .Include(s => s.Uploads)
            .ToArrayAsync(cancellationToken);
        var participants = await questionnaireContext.Participants.ToDictionaryAsync(
            p => p.Id,
            p => p.Name,
            cancellationToken
        );

        {
            var entry = archive.CreateEntry("export.md");
            await using var entryStream = await entry.OpenAsync(cancellationToken);
            await using var writer = new StreamWriter(entryStream);

            await writer.WriteLineAsync(
                $"""
                # Export

                {submissions.Length} svar
                """.AsMemory(),
                cancellationToken
            );
        }

        {
            var entry = archive.CreateEntry("export.json");
            await using var entryStream = await entry.OpenAsync(cancellationToken);
            await JsonSerializer.SerializeAsync(
                entryStream,
                submissions.Select(s =>
                {
                    var participantName = participants[s.Participant];
                    var participantFolder = RemoveInvalidFileNameCharacters(participantName);
                    return new ExportSubmission(
                        s.Id,
                        participantName,
                        s.Answers.Select(a => new ExportAnswer(questions[a.QuestionId], a.Text)),
                        s.Uploads.Select(u => Path.Join(participantFolder, RemoveInvalidFileNameCharacters(u.Name)))
                    );
                }),
                ExportJsonSerializerContext.Default.IEnumerableExportSubmission,
                cancellationToken
            );
        }

        foreach (var submission in submissions)
        {
            var participantName = participants[submission.Participant];
            var participantFolder = RemoveInvalidFileNameCharacters(participantName);

            {
                var entry = archive.CreateEntry(Path.Join(participantFolder, $"{submission.Id}.md"));
                await using var entryStream = await entry.OpenAsync(cancellationToken);
                await using var writer = new StreamWriter(entryStream);

                await writer.WriteLineAsync(
                    $"""
                    # {participantName}

                    """.AsMemory(),
                    cancellationToken
                );

                foreach (var answer in submission.Answers)
                {
                    if (!questions.TryGetValue(answer.QuestionId, out var question))
                    {
                        logger.LogError(
                            "An answer to {QuestionId} exist, but the question does not.",
                            answer.QuestionId
                        );
                        question = answer.QuestionId.ToString();
                    }

                    await writer.WriteLineAsync(
                        $"""
                        ## {question}

                        {answer.Text}

                        """.AsMemory(),
                        cancellationToken
                    );
                }
            }

            foreach (var upload in submission.Uploads)
            {
                var entry = archive.CreateEntry(
                    Path.Join(participantFolder, RemoveInvalidFileNameCharacters(upload.Name))
                );
                await using var entryStream = await entry.OpenAsync(cancellationToken);

                var blobStream = blobStorage.GetBlob(upload.Id);
                if (blobStream is null)
                {
                    logger.LogError("Upload {UploadId} is missing from blob storage.", upload.Id);
                    await using var writer = new StreamWriter(entryStream);
                    await writer.WriteLineAsync("Filen saknas.".AsMemory(), cancellationToken);
                }
                else
                {
                    await using (blobStream)
                    {
                        await blobStream.CopyToAsync(entryStream, cancellationToken);
                    }
                }
            }
        }
    }

    private static string RemoveInvalidFileNameCharacters(string fileName) =>
        string.Join("_", fileName.Split(Path.GetInvalidPathChars()));
}

public static class ExportServiceCollectionExtensions
{
    public static IServiceCollection AddExportService(this IServiceCollection services) =>
        services.AddTransient<ExportService>();
}

public sealed record ExportSubmission(
    Guid Id,
    string Participant,
    IEnumerable<ExportAnswer> Answers,
    IEnumerable<string> Uploads
);

public sealed record ExportAnswer(string Question, string Answer);

[JsonSerializable(typeof(IEnumerable<ExportSubmission>))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)]
public sealed partial class ExportJsonSerializerContext : JsonSerializerContext;