Editor/Effigy/Features/ImportFeature.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

namespace Effigy;

/// <summary>
/// A body that already exists as a mesh file, brought into the history — one body per object in
/// the file, so a character exported with its brows, lids and hair kept separate arrives as a
/// tidy list of parts rather than one welded lump. See <see cref="ObjReader.ReadPieces"/>.
///
/// EFFIGY IS CONSTRUCTIVE. A body is the output of Primitive / Sketch+Extrude / Loft / Sweep /
/// Boolean / Sculpt — there is no other way in. Paint, weight paint and SkinBinder all work on
/// bodies, so a sculpt made outside the tool (Meshy, Blender, a scan) was unreachable: you could
/// look at it, you could not paint it. This feature is that way in.
///
/// THE MESH DOES NOT GO IN THE DOCUMENT. StudioDocument saves public fields by reflection into
/// a text file whose virtue is being readable, and a 60k-triangle OBJ is megabytes of vertices —
/// the same problem SculptFeature solved with a side-car. The document holds a source path. The
/// triangles live in that file, or in a copy next to the .effigy (see <see cref="ImportSidecar"/>),
/// and they are loaded at rebuild.
/// </summary>
public sealed class ImportFeature : Feature
{
	public override string TypeName => "Import";

	/// <summary>The mesh file this feature reads. Wavefront OBJ; anything else is a refusal with
	/// a remedy, not a crash.</summary>
	public readonly StringParam Source = new( "Source" );

	public readonly IntParam Material = new( "Material slot", 0, 0, 63 ) { Slider = false };

	public override IReadOnlyList<IParam> Parameters => new IParam[] { Source, Material };

	public override IReadOnlyList<IParam> AdvancedParameters => new IParam[] { Material };

	// Bytes from a side-car, waiting for the first rebuild. Same reason SculptFeature holds a
	// pending blob: the document has been read, the file on disk may be gone, and the triangles
	// still have to land on a body.
	byte[] _pending;

	// Last successfully parsed pieces, deleted ones included, so a rebuild whose source has not
	// changed does not parse again. Cloned on the way out — downstream features must not mutate
	// the cache.
	List<ObjReader.ObjPiece> _pieces;

	// The source file _pieces was read from, as path, size and write time; null when they came from
	// side-car bytes. See the note in Execute.
	string _piecesStamp;

	// The OBJ bytes that produced _mesh, so a save can write the original rather than a re-export.
	byte[] _objBytes;

	// Piece indices the user has deleted. The source OBJ keeps every piece — deleting one must
	// not rewrite the file — so this records which of the parsed pieces Publish should skip.
	// Public so StudioDocument saves it (see StateFields); cleared when a new source is bound.
	public readonly List<int> RemovedPieces = new();

	/// <summary>True when a side-car has handed this feature bytes that have not been built yet.</summary>
	public bool HasPendingMesh => _pending is not null;

	/// <summary>True once a rebuild has produced a body from this feature.</summary>
	public bool HasMesh => _pieces is not null;

	/// <summary>How many bodies this import produces — one per <c>o</c>/<c>g</c> lump in the file.
	/// Zero until it has been built once.</summary>
	public int PieceCount => _pieces?.Count ?? 0;

	/// <summary>The piece index a body id names, or -1 when the id is not one of this import's
	/// pieces. Body ids are <c>{featureId}b{N}</c>, where N is the position in the parsed file.</summary>
	public int PieceIndexFor( string bodyId )
	{
		if ( _pieces is null || string.IsNullOrWhiteSpace( bodyId ) )
			return -1;

		var prefix = Id + "b";

		if ( !bodyId.StartsWith( prefix, StringComparison.Ordinal ) )
			return -1;

		if ( !int.TryParse( bodyId[prefix.Length..], out var index ) )
			return -1;

		return index >= 0 && index < _pieces.Count ? index : -1;
	}

	/// <summary>How many pieces still publish, after the deleted ones are skipped.</summary>
	public int RemainingPieceCount()
	{
		if ( _pieces is null )
			return 0;

		var remaining = _pieces.Count;

		foreach ( var removed in RemovedPieces )
		{
			if ( removed >= 0 && removed < _pieces.Count )
				remaining--;
		}

		return remaining;
	}

	/// <summary>
	/// Whether deleting this body removes just its own piece. False for a single-piece import —
	/// removing the last piece would leave an empty import, so the caller removes the whole
	/// feature instead.
	/// </summary>
	public bool CanRemovePiece( string bodyId ) =>
		PieceIndexFor( bodyId ) >= 0 && RemainingPieceCount() > 1;

	/// <summary>
	/// Mark one piece as deleted. The source OBJ keeps every piece — deleting one must not rewrite
	/// the file — and <see cref="Publish"/> skips it from here on. The caller marks the studio dirty
	/// and rebuilds.
	/// </summary>
	public bool RemovePiece( string bodyId )
	{
		var index = PieceIndexFor( bodyId );

		if ( index < 0 )
			return false;

		if ( !RemovedPieces.Contains( index ) )
			RemovedPieces.Add( index );

		return true;
	}

	/// <summary>
	/// Whether the cached result is out of date even though nobody called MarkDirty.
	///
	/// A side-car load mutates _pending without going through the dialog, so the rebuild would
	/// reuse an empty snapshot and the imported body would simply not appear. Same reason
	/// SculptFeature answers this.
	/// </summary>
	public override bool IsStale => _pending is not null;

	/// <summary>The OBJ bytes to persist beside the document, or null if nothing has been loaded.</summary>
	public byte[] SaveMesh() => _objBytes ?? _pending;

	/// <summary>Take bytes from a side-car. They are parsed at the next rebuild, not now.</summary>
	public void LoadMesh( byte[] bytes )
	{
		_pending = bytes;
		_objBytes = bytes;
		_pieces = null;
	}

	/// <summary>
	/// Point this feature at a file and, if the file is there, take its bytes so a rebuild does
	/// not depend on the path still existing later.
	/// </summary>
	public void BindSource( string path )
	{
		Source.Value = path ?? "";
		_pieces = null;
		RemovedPieces.Clear();

		if ( !string.IsNullOrWhiteSpace( path ) && File.Exists( path ) )
			LoadMesh( File.ReadAllBytes( path ) );
		else
			_pending = null;
	}

	protected override void Execute( FeatureContext ctx )
	{
		var path = Source.Value?.Trim() ?? "";
		byte[] bytes = null;
		string from = null;
		string stamp = null;

		if ( path.Length > 0 && File.Exists( path ) )
		{
			RefuseIfNotObj( path );

			// THE FILE IS ONLY PARSED AGAIN WHEN IT HAS CHANGED. Deleting a piece, undo and a variable
			// edit all re-run this feature, and re-reading the source each time made every one of them
			// cost the whole file however little of it was left: ten deletes that took a 900k-face
			// Meshy import down to 20k faces still cost 1.3s apiece, where a file holding only the
			// survivors rebuilds in 40ms. Size and write time are the key, so exporting over the same
			// path is still picked up on the next rebuild.
			var info = new FileInfo( path );
			stamp = $"{path}|{info.Length}|{info.LastWriteTimeUtc.Ticks}";

			if ( _pieces is not null && stamp == _piecesStamp )
			{
				Publish( ctx, _pieces );
				return;
			}

			bytes = File.ReadAllBytes( path );
			from = path;
		}
		else if ( _pending is not null )
		{
			bytes = _pending;
			from = "the side-car copy beside this document";
		}
		else if ( _pieces is not null )
		{
			Publish( ctx, _pieces );
			return;
		}
		else if ( path.Length == 0 )
		{
			FailOn( "Source",
				"No mesh file to import",
				"An Import needs a Wavefront OBJ, and this one has no source path.",
				"Pick an .obj file" );
		}
		else
		{
			FailOn( "Source",
				"The mesh file is missing",
				$"Nothing is at '{path}', and this feature has no side-car copy to fall back on.",
				"Pick the file again",
				"Put the OBJ next to this document and point Source at it" );
		}

		List<ObjReader.ObjPiece> pieces;

		try
		{
			pieces = ObjReader.ReadPieces( Encoding.UTF8.GetString( bytes ) );
		}
		catch ( Exception e )
		{
			FailOn( "Source",
				"The mesh file could not be read",
				$"{from} is not a Wavefront OBJ this feature understands: {e.Message}",
				"Export the sculpt as OBJ and pick that" );
			return;
		}

		var vertices = 0;
		var faces = 0;

		foreach ( var piece in pieces )
		{
			vertices += piece.Mesh.VertexCount;
			faces += piece.Mesh.FaceCount;
		}

		if ( vertices == 0 || faces == 0 )
		{
			FailOn( "Source",
				"The mesh file is empty",
				$"{from} has {vertices} vertices and {faces} faces.",
				"Export a mesh that has faces",
				"Pick a different file" );
		}

		_objBytes = bytes;
		_pending = null;
		_pieces = pieces;
		_piecesStamp = stamp;

		Publish( ctx, pieces );
	}

	/// <summary>
	/// One body per lump of the file.
	///
	/// A SINGLE-PIECE FILE KEEPS THE FEATURE'S OWN NAME, so the common case — one object, or an
	/// exporter that wrote no markers at all — reads in the Parts list exactly as it did before
	/// imports could be split. Only a file that really carries several objects grows several rows,
	/// and then the exporter's names are the useful half of the label, prefixed so two imports
	/// cannot produce two rows called "head".
	///
	/// The mesh is cloned on the way out because <see cref="_pieces"/> is a cache: a rebuild that
	/// did not re-read the file publishes from it, and a downstream feature that mutated what it
	/// was handed would corrupt every rebuild after.
	/// </summary>
	void Publish( FeatureContext ctx, List<ObjReader.ObjPiece> pieces )
	{
		var slot = Material.Clamped;

		// How many pieces will actually publish, after the deleted ones are skipped. A single
		// SURVIVOR keeps the feature's own name, the same way a single-piece file does.
		var remaining = pieces.Count;

		foreach ( var removed in RemovedPieces )
		{
			if ( removed >= 0 && removed < pieces.Count )
				remaining--;
		}

		var single = remaining == 1;

		for ( var i = 0; i < pieces.Count; i++ )
		{
			var piece = pieces[i];

			// The id is claimed for a removed piece too, so deleting one never renumbers the
			// survivors — BodyNames, rig bindings and downstream body selections all key on
			// {featureId}b{N} and must keep meaning the same piece.
			var id = ctx.NewBodyId();

			if ( RemovedPieces.Contains( i ) )
				continue;

			var mesh = piece.Mesh.Clone();

			if ( slot != 0 )
			{
				foreach ( var face in mesh.Faces )
					face.Material = slot;
			}

			var label = single || string.IsNullOrWhiteSpace( piece.Name )
				? Name
				: $"{Name} / {piece.Name}";

			ctx.Bodies.Add( new Body( id, label, mesh ) );
		}
	}

	static void RefuseIfNotObj( string path )
	{
		var ext = Path.GetExtension( path );

		if ( string.Equals( ext, ".obj", StringComparison.OrdinalIgnoreCase ) )
			return;

		var shown = string.IsNullOrEmpty( ext ) ? "no extension" : ext;
		FailOn( "Source",
			"Import reads Wavefront OBJ",
			$"'{path}' is {shown}. FBX, GLB and the rest are writers in this kernel, not readers.",
			"Export the mesh as OBJ and pick that" );
	}
}

/// <summary>
/// Where an imported mesh lives next to a document.
///
/// One directory beside the .effigy file, one OBJ per import feature, named by that feature's id.
/// Keyed by id rather than by position so re-ordering the history or renaming the feature does not
/// hand somebody else's sculpt to this one.
///
/// Saving does not delete files it did not write — same rule as <see cref="SculptSidecar"/>. A
/// file whose feature is gone is the cheapest undo of "I deleted the import and saved".
/// <see cref="Prune"/> exists for when that is actually wanted.
/// </summary>
public static class ImportSidecar
{
	public const string FolderSuffix = ".import";

	public static string DirectoryFor( string documentPath )
	{
		if ( string.IsNullOrWhiteSpace( documentPath ) )
			throw new ArgumentException( "A document path is needed to find its imported meshes.", nameof( documentPath ) );

		var dir = Path.GetDirectoryName( documentPath ) ?? "";
		return Path.Combine( dir, Path.GetFileNameWithoutExtension( documentPath ) + FolderSuffix );
	}

	public static string PathFor( string documentPath, string featureId ) =>
		Path.Combine( DirectoryFor( documentPath ), featureId + ".obj" );

	/// <summary>Write an OBJ for every import feature that has one. Returns how many it wrote.</summary>
	public static int Save( PartStudio studio, string documentPath )
	{
		if ( studio is null )
			throw new ArgumentNullException( nameof( studio ) );

		var pending = new List<(string Id, byte[] Bytes)>();

		foreach ( var feature in studio.Features )
		{
			if ( feature is not ImportFeature import )
				continue;

			var bytes = import.SaveMesh();

			if ( bytes is not null )
				pending.Add( (feature.Id, bytes) );
		}

		if ( pending.Count == 0 )
			return 0;

		Directory.CreateDirectory( DirectoryFor( documentPath ) );

		foreach ( var (id, bytes) in pending )
			File.WriteAllBytes( PathFor( documentPath, id ), bytes );

		return pending.Count;
	}

	/// <summary>Hand each import feature its OBJ. Parsed at the next rebuild, not now.</summary>
	public static int Load( PartStudio studio, string documentPath )
	{
		if ( studio is null )
			throw new ArgumentNullException( nameof( studio ) );

		var dir = DirectoryFor( documentPath );

		if ( !Directory.Exists( dir ) )
			return 0;

		var loaded = 0;

		foreach ( var feature in studio.Features )
		{
			if ( feature is not ImportFeature import )
				continue;

			var path = PathFor( documentPath, feature.Id );

			if ( !File.Exists( path ) )
				continue;

			import.LoadMesh( File.ReadAllBytes( path ) );
			loaded++;
		}

		return loaded;
	}

	/// <summary>Delete OBJs no feature in this studio claims. Destructive, so it is never part of
	/// saving — see the note on this class.</summary>
	public static int Prune( PartStudio studio, string documentPath )
	{
		if ( studio is null )
			throw new ArgumentNullException( nameof( studio ) );

		var dir = DirectoryFor( documentPath );

		if ( !Directory.Exists( dir ) )
			return 0;

		var keep = new HashSet<string>( StringComparer.Ordinal );

		foreach ( var feature in studio.Features )
		{
			if ( feature is ImportFeature )
				keep.Add( feature.Id + ".obj" );
		}

		var removed = 0;

		foreach ( var path in Directory.GetFiles( dir, "*.obj" ) )
		{
			if ( keep.Contains( Path.GetFileName( path ) ) )
				continue;

			File.Delete( path );
			removed++;
		}

		return removed;
	}
}