Editor/Server/JobStorage.cs
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;

namespace HumanoidMocapHost;

/// <summary>Job files plus the host's saved settings.</summary>
internal interface IJobStorage
{
    Stream CreateVideo(Guid id);
    Stream OpenVideo(Guid id);
    string VideoPath(Guid id);
    Stream CreateMotion(Guid id);
    Stream OpenMotion(Guid id);
    void DeleteVideo(Guid id);
    void Delete(Guid id);
    HostSettings? LoadSettings();
    void SaveSettings(HostSettings settings);
}

internal sealed class FileJobStorage : IJobStorage, IDisposable
{
    const string LockName = "run.lock", SettingsName = "settings.json";
    static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
    readonly string directory, root;
    // Held for the service lifetime so a later start can tell live run folders from abandoned ones.
    readonly FileStream runLock;
    internal string CaptureDirectory => Path.Combine(root, "captures");

    /// <summary>Uses a new run folder under <paramref name="directory"/>, after deleting run folders
    /// that no live host holds. Several hosts may share the directory.</summary>
    internal FileJobStorage(string directory)
    {
        this.directory = Path.GetFullPath(directory);
        Directory.CreateDirectory(this.directory);
        RemoveAbandonedRuns();
        root = Path.Combine(this.directory, Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(root);
        runLock = new FileStream(Path.Combine(root, LockName), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None);
    }

    // A stopped or crashed run's jobs can no longer be listed or deleted on schedule, so its files
    // go at the next start; that keeps results from outliving the 24-hour promise.
    void RemoveAbandonedRuns()
    {
        foreach (var run in Directory.EnumerateDirectories(directory))
        {
            if (!Guid.TryParseExact(Path.GetFileName(run), "N", out _)) continue;
            var marker = Path.Combine(run, LockName);
            if (!File.Exists(marker)) continue;
            try
            {
                using (new FileStream(marker, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { }
                Directory.Delete(run, true);
            }
            catch (IOException) { } // Locked: another host is still running there.
        }
    }

    string PathFor(Guid id, string suffix) => Path.Combine(root, id.ToString("N") + suffix);
    static FileStream Create(string path) => new(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, true);
    static FileStream Open(string path) => new(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, 65536, true);
    public Stream CreateVideo(Guid id) => Create(PathFor(id, ".video"));
    public Stream OpenVideo(Guid id) => Open(PathFor(id, ".video"));
    public string VideoPath(Guid id) => PathFor(id, ".video");
    public Stream CreateMotion(Guid id) => Create(PathFor(id, ".hmotion"));
    public Stream OpenMotion(Guid id) => Open(PathFor(id, ".hmotion"));
    public void DeleteVideo(Guid id) => File.Delete(PathFor(id, ".video"));
    public void Delete(Guid id)
    {
        DeleteVideo(id);
        File.Delete(PathFor(id, ".hmotion"));
    }

    public HostSettings? LoadSettings()
    {
        var path = Path.Combine(directory, SettingsName);
        if (!File.Exists(path)) return null;
        try { return JsonSerializer.Deserialize<HostSettings>(File.ReadAllText(path), Json) ?? throw new JsonException("empty"); }
        catch (JsonException error) { throw new InvalidDataException($"{path} is not valid host settings. Fix or delete it.", error); }
    }

    public void SaveSettings(HostSettings settings)
    {
        var path = Path.Combine(directory, SettingsName);
        var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
        try
        {
            File.WriteAllText(temporary, JsonSerializer.Serialize(settings, Json));
            File.Move(temporary, path, true);
        }
        finally { if (File.Exists(temporary)) File.Delete(temporary); }
    }

    /// <summary>Releases the run folder's lock; its files are removed by the next host start.</summary>
    public void Dispose() => runLock.Dispose();

    /// <summary>Deletes this run after every reader/writer has drained. Other run folders
    /// and saved settings remain intact; deletion failures propagate to the operator.</summary>
    internal void DeleteRun()
    {
        runLock.Dispose();
        Directory.Delete(root, true);
    }
}