Editor/HumanoidMocap/MocapAdjustmentStore.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using HumanoidMocap.Cleanup;
using HumanoidMocap.Motion;

namespace HumanoidMocap.Editor;

/// <summary>Applied editor corrections beside a motion file, never in its raw channels.</summary>
public static class MocapAdjustmentStore
{
    public sealed class TargetEdit
    {
        public TargetCorrectionSettings Corrections { get; set; } = new();
        public RootMotionMode RootMotion { get; set; }
        public float Fov { get; set; } = 75;
        public float ViewPitch { get; set; }
        public float NearClip { get; set; } = 15;
    }
    public sealed class State
    {
        public int Schema { get; set; } = 1;
        public string InputHash { get; set; } = "";
        public string RawPath { get; set; } = "";
        public string RawHash { get; set; } = "";
        public CleanupSettings? Cleanup { get; set; }
        public List<ContactInterval> Contacts { get; set; } = new();
        public Dictionary<string,TargetEdit> Targets { get; set; } = new();
    }
    public sealed record Session(string Path,State State,MotionDocument Raw,MotionDocument Edited,string? Notice);
    static JsonSerializerOptions Options=>new(MotionDocument.JsonOptions){IncludeFields=true};
    static string Hash(byte[] bytes)=>Convert.ToHexString(SHA256.HashData(bytes));

    public static Session Load(string path,string? originalPath=null,CleanupSettings? initialCleanup=null)
    {
        path=System.IO.Path.GetFullPath(path);
        var inputBytes=File.ReadAllBytes(path);var input=MotionDocument.Parse(inputBytes);var inputHash=Hash(inputBytes);
        var storePath=path+".adjustments.json";State? state=null;string? notice=null;
        var rawBytes=originalPath is null?inputBytes:File.ReadAllBytes(originalPath);
        var rawHash=Hash(rawBytes);
        if(File.Exists(storePath))
        {
            if(new FileInfo(storePath).Length>1024*1024)throw new InvalidDataException("Saved adjustments exceed the 1 MiB limit.");
            state=JsonSerializer.Deserialize<State>(File.ReadAllText(storePath),Options)
                ??throw new InvalidDataException("Empty adjustment file.");
            Validate(state);
            if(state.InputHash!=inputHash&&(originalPath is null||state.RawHash!=rawHash))
            {state=null;notice="The motion changed; saved adjustments were not applied.";}
        }
        var restored=state is not null;
        if(state is not null)
        {
            // A missing/changed original must never silently become already-cleaned input.
            rawBytes=File.ReadAllBytes(state.RawPath);
            if(Hash(rawBytes)!=state.RawHash)throw new InvalidDataException("Original reconstruction changed. Restore it before reopening these adjustments.");
            state.InputHash=inputHash;
        }
        else state=new State{InputHash=inputHash,RawPath=System.IO.Path.GetFullPath(originalPath??path),RawHash=rawHash,
            Cleanup=initialCleanup,Contacts=input.Copy().Contacts};
        var raw=MotionDocument.Parse(rawBytes);
        var source=raw.Copy();source.Contacts=state.Contacts;
        var edited=restored?(state.Cleanup is null?source:MotionCleanup.Apply(source,state.Cleanup)):input;
        edited.Contacts=state.Contacts;edited.Validate();
        return new(storePath,state,raw,edited,notice);
    }

    public static string TargetKey(RetargetTargetSpec spec,bool firstPerson)
    {
        var data=JsonSerializer.Serialize(new{spec.UpAxis,spec.VmdlScale,firstPerson,bones=spec.Rig.Skeleton.Bones.Select(b=>new{
            b.Name,b.ParentIndex,role=spec.Rig.RoleOf(b.Index),position=MotionDocument.A(b.RestLocal.Pos),rotation=MotionDocument.A(b.RestLocal.Rot)})});
        return Hash(Encoding.UTF8.GetBytes(data));
    }

    public static void Save(Session session)
    {
        Validate(session.State);
        var json=JsonSerializer.Serialize(session.State,Options);
        if(Encoding.UTF8.GetByteCount(json)>1024*1024)throw new InvalidDataException("Saved adjustments exceed the 1 MiB limit.");
        var temporary=session.Path+"."+Guid.NewGuid().ToString("N")+".tmp";
        try{File.WriteAllText(temporary,json);File.Move(temporary,session.Path,true);}
        finally{if(File.Exists(temporary))File.Delete(temporary);}
    }

    static void Validate(State state)
    {
        if(state.Schema!=1||state.Targets is null||state.Contacts is null||state.Targets.Count>64
            ||string.IsNullOrEmpty(state.RawPath)||state.Contacts.Any(c=>c is null||!Enum.IsDefined(typeof(ContactReview),c.Review)))
            throw new InvalidDataException("Unsupported saved adjustments.");
        foreach(var edit in state.Targets.Values)
        {
            if(edit?.Corrections is null||!Enum.IsDefined(typeof(RootMotionMode),edit.RootMotion)
                ||!float.IsFinite(edit.Fov)||!float.IsFinite(edit.ViewPitch)||!float.IsFinite(edit.NearClip))
                throw new InvalidDataException("Invalid saved target adjustments.");
            WristPositionOffsets.Validate(edit.Corrections.WristOffsets);
        }
        if(state.Cleanup is { } c&&(!float.IsFinite(c.Root)||!float.IsFinite(c.Arms)||!float.IsFinite(c.Fingers)
            ||!float.IsFinite(c.PreserveAngularSpeed)||c.PreserveAngularSpeed<0))
            throw new InvalidDataException("Invalid saved cleanup settings.");
    }
}