Editor/HumanoidHandRetargeter/HandRetargetWindow.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using HumanoidHandRetargeter.Retargeting;
using HumanoidHandRetargeter.Skeleton;

namespace HumanoidHandRetargeter.Editor;

/// <summary>The audited RetargetWindow shell specialized for hands, with the same toolbar,
/// scroll rows, three option columns, report area and status strip.</summary>
[Dock("Editor",DockTitle,"sync_alt")]
public sealed class HandRetargetWindow : Widget
{
    public const string DockTitle="Humanoid Hand Retargeter";
    static HandRetargetWindow? instance;
    readonly List<Row> rows=new();readonly Layout list;readonly Label status;readonly Label report;
    readonly Button convert,add,details;readonly ComboBox targets;readonly Widget options;
    readonly Checkbox graph,backup,ik,travel,scale,companion,weapon,grip;
    readonly LineEdit output,fps;bool busy;CancellationTokenSource? cancellation;
    public HandTarget? Target {get;private set;}
    public IReadOnlyList<HandBakedClip> LastBake {get;private set;}=Array.Empty<HandBakedClip>();
    public bool AutoConfigureAnimGraph {get=>graph.Value;set=>graph.Value=value;}
    public string OutputPath {get=>output.Text;set=>output.Text=value;}

    public HandRetargetWindow(Widget? parent) : base(parent)
    {
        instance=this;Name="HumanoidHandRetargeter";WindowTitle=DockTitle;SetWindowIcon("sync_alt");MinimumSize=new Vector2(720,420);
        Layout=Layout.Column();var top=Layout.AddRow();top.Margin=8;top.Spacing=8;
        add=top.Add(new Button.Primary("Add Files…"){Icon="add",Clicked=PickSources});
        top.AddSpacingCell(8);top.Add(new Label(this){Text="Target:"});targets=top.Add(new ComboBox(this){MinimumWidth=190});
        targets.AddItem("Human FPS arms","person",()=>_=SelectTargetAsync(HandEditorPipeline.HumanArms),selected:true);
        targets.AddItem("Citizen FPS arms","person_outline",()=>_=SelectTargetAsync(HandEditorPipeline.CitizenArms));
        targets.AddItem("Custom model (.vmdl)…","view_in_ar",PickTargetModel);
        targets.AddItem("Custom hands (.fbx)…","category",PickTargetFbx);
        top.AddSpacingCell(8);top.Add(new Label(this){Text="Output:"});var outputMode=top.Add(new ComboBox(this){MinimumWidth=200});
        outputMode.AddItem("New animation vmdl","note_add",()=>{if(output is not null)output.Text="models/hand_retargeter/retargeted_hands.vmdl";},selected:true);
        outputMode.AddItem("Add to existing vmdl…","library_add",()=>{
            var picker=AssetPicker.Create(this,AssetType.Model);picker.OnAssetPicked=assets=>{var asset=assets.FirstOrDefault();if(asset is not null && output is not null)output.Text=asset.Path;};picker.Show();
        });
        top.AddStretchCell();convert=top.Add(new Button.Primary("Convert All"){Icon="play_arrow",Tint=Theme.Green,Clicked=()=>_=ConvertAsync()});
        var scroll=Layout.Add(new ScrollArea(this),1);scroll.Canvas=new Widget(scroll);scroll.Canvas.Layout=Layout.Column();scroll.Canvas.Layout.Margin=new Sandbox.UI.Margin(8,4,16,4);scroll.Canvas.Layout.Spacing=2;list=scroll.Canvas.Layout;
        options=Layout.Add(new Group(this){Title="Options",Icon="tune"});options.Layout=Layout.Row();options.Layout.Margin=new Sandbox.UI.Margin(14,30,14,12);options.Layout.Spacing=24;
        var a=options.Layout.AddColumn();a.Spacing=6;
        graph=a.Add(new Checkbox("Auto Configure AnimGraph"){Value=true});graph.ToolTip="Preserve the complete source weapon graph with live custom-hand retargeting. Sources without a graph get a generated action graph. Creates a synchronized weapon prefab; existing assets are preserved.";
        backup=a.Add(new Checkbox("Back up target VMDL"){Value=true});
        weapon=a.Add(new Checkbox("Weapon-compatible arms"){Value=false});weapon.ToolTip="Keep animation graph ownership on the weapon. Arms are bonemerged onto it.";
        companion=a.Add(new Checkbox("Preserve source tracks"){Value=true});companion.ToolTip="Also export the original hierarchy and animation as a companion DMX, preserving weapon, camera and IK tracks.";
        var b=options.Layout.AddColumn();b.Spacing=6;
        ik=b.Add(new Checkbox("Arm effector IK"){Value=true});travel=b.Add(new Checkbox("Transfer wrist travel"){Value=true});scale=b.Add(new Checkbox("Scale wrist travel to target"){Value=true});
        grip=b.Add(new Checkbox("Preserve weapon grip"){Value=true});grip.ToolTip="For weapon model sources, keep both palm anchors on the authored weapon motion. Per-arm travel scaling is bypassed.";
        b.Add(new Button("Target Mapping…","device_hub"){Clicked=MapTarget});
        var select=b.AddRow();select.Add(new Button("Select all"){Clicked=()=>{foreach(var row in rows)row.Selected=true;Refresh();}});select.Add(new Button("Select none"){Clicked=()=>{foreach(var row in rows)row.Selected=false;Refresh();}});
        var c=options.Layout.AddColumn();c.Spacing=6;
        c.Add(new Label(this){Text="Output model:"});output=c.Add(new LineEdit(this){Text="models/hand_retargeter/retargeted_hands.vmdl",MinimumWidth=200});
        var sample=c.AddRow();sample.Spacing=8;sample.Add(new Label(this){Text="Sample fps:"});fps=sample.Add(new LineEdit(this){Text="30",FixedWidth=52});
        foreach(var check in new[]{ik,travel,scale,grip})check.Clicked=()=>LastBake=Array.Empty<HandBakedClip>();
        report=Layout.Add(new Label(this){WordWrap=true,Visible=false});report.SetStyles("margin: 8px;");
        var bottom=Layout.AddRow();bottom.Margin=new Sandbox.UI.Margin(8,4,8,6);bottom.Spacing=8;
        status=bottom.Add(new Label(this){Text="Select source animations and a target hand model."},1);
        details=bottom.Add(new Button("Show details"){Visible=false,Clicked=ToggleDetails});
        bottom.Add(new Button("Cancel"){Clicked=()=>cancellation?.Cancel()});
        _=SelectTargetAsync(HandEditorPipeline.HumanArms);
    }
    public static HandRetargetWindow? Open()
    {
        if(instance is null||!instance.IsValid)EditorWindow.DockManager.SetDockState(DockTitle,true);
        if(instance is { IsValid: true })EditorWindow.DockManager.RaiseDock(instance);return instance;
    }
    void PickSources(){var picker=new FileDialog(null){Title="Add hand animations"};picker.SetFindExistingFiles();picker.SetModeOpen();picker.SetNameFilter("Hand animation files (*.fbx *.vmdl *.vmdl_c)");if(picker.Execute())_=AddFilesAsync(picker.SelectedFiles.ToArray());}
    void PickTargetModel(){var picker=AssetPicker.Create(this,AssetType.Model);picker.OnAssetPicked=assets=>{var asset=assets.FirstOrDefault();if(asset is not null)_=SelectTargetAsync(asset.Path);};picker.Show();}
    void PickTargetFbx(){var picker=new FileDialog(null){Title="Select target FPS hands"};picker.SetFindExistingFiles();picker.SetModeOpen();picker.SetNameFilter("Hand models (*.fbx)");if(picker.Execute()&&picker.SelectedFiles.FirstOrDefault() is {} path)_=SelectTargetAsync(path);}

    public Task SelectTargetAsync(string path)=>Run(async token=>{
        Target=await HandEditorPipeline.LoadTargetAsync(path,token);
        targets.CurrentIndex=path.Equals(HandEditorPipeline.HumanArms,StringComparison.OrdinalIgnoreCase)?0
            :path.Equals(HandEditorPipeline.CitizenArms,StringComparison.OrdinalIgnoreCase)?1
            :path.EndsWith(".fbx",StringComparison.OrdinalIgnoreCase)?3:2;
        LastBake=Array.Empty<HandBakedClip>();
        report.Text=string.Join("\n",Target.ImportNotes);
        status.Text="Target: "+System.IO.Path.GetFileName(path)+(Target.Mapping.NeedsReview?" — confirm Target Mapping":"");
    });
    public Task AddFilesAsync(IEnumerable<string> paths)=>Run(async token=>{
        var rate=float.TryParse(fps.Text,out var f)&&float.IsFinite(f)&&f>0&&f<=240?f:30;
        foreach(var path in paths){
            token.ThrowIfCancellationRequested();status.Text="Importing "+System.IO.Path.GetFileName(path)+"…";
            var source=await HandEditorPipeline.LoadSourceAsync(path,rate,token);await HandEditorPipeline.MainThread();
            if(source.Scene.Clips.Count==0)throw new InvalidOperationException("The source file contains no animation clips.");
            var preferred=source.Scene.Clips.FirstOrDefault(c=>c.Name.Contains("reload",StringComparison.OrdinalIgnoreCase))??source.Scene.Clips[0];
            foreach(var clip in source.Scene.Clips)rows.Add(new(source,clip){Selected=source.ModelPath is null||ReferenceEquals(clip,preferred)});
        }
        status.Text=$"{rows.Count} source clip(s). Select the clips to convert.";
    });
    void MapTarget()
    {
        if(Target is null)return;var target=Target;var dialog=new HandMappingDialog(this,"Target",target.Skeleton,target.Mapping,target.PalmFrames);
        dialog.Applied=map=>{target.Mapping=map;target.PalmFrames=dialog.PalmFrames;LastBake=Array.Empty<HandBakedClip>();Refresh();};dialog.Show();
    }
    void MapSource(HandSource source){var dialog=new HandMappingDialog(this,System.IO.Path.GetFileName(source.Path),source.Scene.Skeleton,source.Mapping,source.PalmFrames);dialog.Applied=map=>{source.Mapping=map;source.PalmFrames=dialog.PalmFrames;LastBake=Array.Empty<HandBakedClip>();Refresh();};dialog.Show();}
    public Task PreviewAsync(int index)=>Run(async token=>{
        if(Target is null)throw new InvalidOperationException("Choose a target first.");
        var target=Target;var selected=rows[index];var options=MotionOptions();
        var baked=await Task.Run(()=>HandEditorPipeline.Bake(selected.Source,selected.Animation,target,options,token),token);
        await HandEditorPipeline.MainThread();LastBake=new[]{baked};
        var dialog=new HandPreviewDialog(this,target,LastBake);dialog.Confirmed=()=>_=ExportBakedAsync(target,new[]{baked});dialog.Show();
    });
    public Task ConvertAsync()=>Run(async token=>{
        if(Target is null)throw new InvalidOperationException("Choose a target first.");
        var selected=rows.Where(r=>r.Selected).ToArray();if(selected.Length==0)throw new InvalidOperationException("Select at least one source clip.");
        var target=Target;var options=MotionOptions();var baked=new List<HandBakedClip>();
        var profiles=new Dictionary<HandSource,HumanoidHandRetargeter.Calibration.HandRetargetProfile>();
        for(var i=0;i<selected.Length;i++){
            status.Text=$"Retargeting {i+1} / {selected.Length}: {selected[i].Animation.Name}";
            var row=selected[i];baked.Add(await Task.Run(()=>{if(!profiles.TryGetValue(row.Source,out var profile)){profile=HandEditorPipeline.Calibrate(row.Source,target);profiles.Add(row.Source,profile);}return HandEditorPipeline.Bake(row.Source,row.Animation,target,options,token,profile);},token));await HandEditorPipeline.MainThread();
        }
        LastBake=baked;await Export(target,baked,token);
    });
    Task ExportBakedAsync(HandTarget target,IReadOnlyList<HandBakedClip> clips)=>Run(token=>Export(target,clips,token));
    async Task Export(HandTarget target,IReadOnlyList<HandBakedClip> clips,CancellationToken token)
    {
        status.Text="Writing and compiling animations…";
        var result=await HandEditorPipeline.ExportWithReportAsync(target,clips,output.Text,graph.Value,backup.Value,weapon.Value,companion.Value,token);
        await HandEditorPipeline.MainThread();status.Text=$"Done: {clips.Count} clip(s) → {result.ModelPath}";
        if(result.WeaponPrefabs is {Count:>0})status.Text+=$"\nCreated {result.WeaponPrefabs.Count} weapon prefab(s). Paths are under Show details.";
        report.Text=string.Join("\n",result.Changes.Concat(target.ImportNotes).Concat(clips.SelectMany(c=>c.Notes)).Distinct())+(result.BackupPath is null?"":"\nBackup: "+result.BackupPath)+(companion.Value?"\nOriginal weapon/camera/IK tracks saved in the source_tracks folder.":"");
        report.Visible=false;details.Text="Show details";details.Visible=true;
    }
    void ToggleDetails(){report.Visible=!report.Visible;details.Text=report.Visible?"Hide details":"Show details";}
    HandMotionOptions MotionOptions()=>new(){SolveArmIk=ik.Value,TransferWristPosition=travel.Value,ScaleWristTravel=scale.Value,PreserveWeaponGrip=grip.Value};
    async Task Run(Func<CancellationToken,Task> work)
    {
        if(busy)return;busy=true;cancellation=new();report.Visible=false;details.Visible=false;Refresh();
        try{await work(cancellation.Token);}
        catch(OperationCanceledException){await HandEditorPipeline.MainThread();status.Text="Cancelled.";}
        catch(Exception error){await HandEditorPipeline.MainThread();status.Text="Action could not be completed.";report.Text=error.Message;report.Visible=true;}
        finally{await HandEditorPipeline.MainThread();busy=false;cancellation.Dispose();cancellation=null;if(IsValid)Refresh();}
    }
    void Refresh()
    {
        add.Enabled=!busy;targets.Enabled=!busy;options.Enabled=!busy;convert.Enabled=!busy&&Target is not null&&rows.Any(r=>r.Selected);
        list.Clear(true);
        foreach(var row in rows){
            var widget=new Widget(this);widget.Layout=Layout.Row();widget.Layout.Margin=new Sandbox.UI.Margin(32,4,8,4);widget.Layout.Spacing=8;
            var selection=widget.Layout.Add(new Checkbox(""){Value=row.Selected,Enabled=!busy});selection.Clicked=()=>{row.Selected=selection.Value;convert.Enabled=!busy&&Target is not null&&rows.Any(r=>r.Selected);};
            widget.Layout.Add(new Label(this){Text=System.IO.Path.GetFileName(row.Source.Path)+" · "+row.Animation.Name},1).SetStyles("font-weight:600;");
            var chip=widget.Layout.Add(new Label(this){Text=row.Source.Mapping.NeedsReview?"Needs review":"Mapped",FixedHeight=20});
            chip.SetStyles($"background-color:{(row.Source.Mapping.NeedsReview?Theme.Yellow:Theme.Green).WithAlpha(.18f).Hex};border-radius:10px;padding:2px 8px;");
            widget.Layout.Add(new Button("Mapping…","device_hub"){Enabled=!busy,Clicked=()=>MapSource(row.Source)});
            widget.Layout.Add(new Button("Preview…","preview"){Enabled=!busy&&Target is not null,Clicked=()=>_=PreviewAsync(rows.IndexOf(row))});
            widget.Layout.Add(new IconButton("close"){Enabled=!busy,ToolTip="Remove this take from the list",OnClick=()=>{rows.Remove(row);Refresh();}});list.Add(widget);
        }
        list.AddStretchCell();
    }
    public override void OnDestroyed(){cancellation?.Cancel();if(ReferenceEquals(instance,this))instance=null;base.OnDestroyed();}
    sealed class Row {public HandSource Source;public Clip Animation;public bool Selected=true;public Row(HandSource source,Clip clip){Source=source;Animation=clip;}}
}