Editor/GeppettoPublish.cs
using Editor;
using Sandbox;
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace Toolshed.Publishing;

/// <summary>
/// Publish the Geppetto library package from the console, with the change notes filled in from the
/// last commit.
///
/// WHY THIS EXISTS. `tools/ship.sh` puts a change on both git repos in one command, but the s&amp;box
/// package - the copy that reaches people who INSTALLED Geppetto rather than cloned it - was a
/// four-screen wizard ending in a "Change Title" box pre-filled with "Changes on 2026-09-04",
/// which says nothing to anyone. The commit message already says what changed. Retyping a worse
/// version of it into a dialog is the kind of step that gets skipped, and a skipped publish means
/// installed users keep running the bug you just fixed.
///
/// HOW IT WORKS. `Editor.ProjectPublisher` is the type the wizard itself drives, and its whole
/// flow is public: FromProject, PrePublish, UploadFiles, Publish, SetChangeDetails. This calls
/// them in that order. Nothing here reimplements the upload; it presses the same buttons.
///
/// DRY RUN BY DEFAULT, and that is deliberate rather than cautious boilerplate. A publish is
/// visible to everyone who installed the package and cannot be taken back - the version is out.
/// So `geppetto_publish` alone reports what WOULD go, and only `geppetto_publish commit` sends it.
/// The wizard's four screens were, among other things, four chances to notice you were about to
/// publish the wrong thing; this keeps one of them.
///
///     geppetto_publish              what would be published, uploads nothing
///     geppetto_publish commit       publish it, notes taken from the last commit
///     geppetto_publish commit force ... even with stray content in Assets/
///
/// IT PRINTS WHAT IS IN THE BOX, AND REFUSES ON STRAYS. A publish ships whatever sits in the
/// project directory - .gitignore does not reach it, and neither does the .sbproj's `Resources`
/// line - and until this printed a breakdown, nothing in the process ever named a file. On
/// 2026-09-10 a dry run reported "687 total, 231 to upload (459181kb)": 434MB of modelling scratch
/// under Assets/ that had been going out to everyone who installed the package for revisions,
/// alongside a folder of private dev notes. Both were invisible because the only number anybody
/// ever saw was a count. See Breakdown and IsStray below.
///
/// INTERNAL API, SO IT CAN BREAK. These are editor types, not a documented contract, and an engine
/// update may move them. The failure is loud - a missing method throws here rather than publishing
/// something wrong - and the wizard is always still there.
/// </summary>
public static class GeppettoPublish
{
	[ConCmd( "geppetto_publish" )]
	public static void Run( string mode = "", string flag = "" )
	{
		var commit = string.Equals( mode, "commit", StringComparison.OrdinalIgnoreCase );
		var force = string.Equals( flag, "force", StringComparison.OrdinalIgnoreCase );

		_ = PublishAsync( commit, force );
	}

	static async Task PublishAsync( bool commit, bool force )
	{
		try
		{
			var root = Project.Current?.RootDirectory?.FullName;

			if ( string.IsNullOrWhiteSpace( root ) )
			{
				Log.Error( "[publish] no current project - open Geppetto in the editor first" );
				return;
			}

			// THE OPEN PROJECT IS THE ONE TO PUBLISH, and that is only true since Geppetto
			// stopped being a folder inside another project. It used to have to find its own
			// .sbproj under the host's Libraries/ and be careful not to publish the host by
			// mistake - which is exactly the mistake that got made by hand, creating a stray
			// pooh.toolshed package. There is one project open here and it is this one.
			var project = Project.Current;

			Log.Info( $"[publish] project   {project.Config?.Ident} ({project.Config?.Type}), "
				+ $"published={project.IsPublished}" );

			var publisher = await ProjectPublisher.FromProject( project );

			if ( publisher is null )
			{
				Log.Error( "[publish] ProjectPublisher.FromProject returned nothing" );
				return;
			}

			var (title, detail) = ChangeNotes( root );

			publisher.SetChangeDetails( title, detail );

			// THE PUBLISH DIES WITHOUT THIS, and not anywhere near here. OnProgressChanged is the
			// wizard's progress bar, and nothing else sets it - so from the console it stays null.
			// UploadFile reports progress through MainThread.Queue, the queued lambda invokes a
			// null Action, and the NullReferenceException surfaces with Queue as its top frame and
			// no mention of a delegate at all. It reads exactly like a threading fault, which cost
			// an afternoon of chasing one.
			//
			// It hid until there was something to send: a publish with zero files to upload never
			// reports progress, so the very first run of this - which had nothing to upload - was
			// green.
			var uploaded = 0;

			publisher.OnProgressChanged = () =>
			{
				// A line per file rather than per callback: progress fires several times per file
				// and a console is not a progress bar.
				var done = publisher.TotalFileCount - publisher.MissingFileCount;

				if ( done <= uploaded )
					return;

				uploaded = done;

				Log.Info( $"[publish] uploaded  {done}/{publisher.TotalFileCount}" );
			};

			// PrePublish is what the wizard runs between its screens - it builds the manifest, so
			// the file counts below are meaningless before it.
			await publisher.PrePublish( CancellationToken.None );

			Log.Info( $"[publish] package   {publisher.TargetPackageIdent}" );
			Log.Info( $"[publish] from      {root}" );
			Log.Info( $"[publish] files     {publisher.TotalFileCount} total, "
				+ $"{publisher.MissingFileCount} to upload ({publisher.MissingFileSize / 1024}kb)" );
			Log.Info( $"[publish] title     {title}" );

			if ( !string.IsNullOrWhiteSpace( detail ) )
				Log.Info( $"[publish] detail    {detail.Replace( "\n", " / " )}" );

			var strays = Breakdown( publisher );

			var before = await VersionOf( publisher.TargetPackageIdent );

			Log.Info( $"[publish] live now  {Describe( before )}" );

			if ( !commit )
			{
				Log.Info( "[publish] DRY RUN - nothing uploaded. `geppetto_publish commit` to send it." );
				return;
			}

			// THE GUARD. Everything above is a report; this is the one that stops.
			if ( strays > 0 && !force )
			{
				Log.Error( $"[publish] REFUSING - {strays} file(s) under Assets/ that are not the "
					+ "package's own content. They are listed above as STRAY." );
				Log.Error( "[publish] Move them out of the project directory, or run "
					+ "`geppetto_publish commit force` if you meant to ship them." );
				return;
			}

			Log.Info( "[publish] uploading..." );

			var toUpload = publisher.MissingFileCount;

			await publisher.UploadFiles();

			// UPLOADFILES DOES NOT THROW ON A REJECTED FILE. Every failure is logged by the engine
			// and the task completes exactly as it does on success, so "it returned" says nothing
			// about whether anything landed. On 2026-09-05 an expired editor login sent all 450
			// files back 401 Unauthorized, and because nothing threw, this ran on to report the
			// unmoved version as "byte-identical content" - an explanation for a publish that had
			// never happened, which cost hours pointed at the wrong thing.
			//
			// The manifest is the one that knows: it counts what has NOT arrived, and the upload
			// loop walks that number down as files land. If it has not moved, neither did they.
			var stillMissing = publisher.MissingFileCount;

			if ( toUpload > 0 && stillMissing >= toUpload )
			{
				Log.Error( $"[publish] NOTHING UPLOADED - all {toUpload} files were rejected, and "
					+ "nothing was published." );
				Log.Error( "[publish] the usual cause is an expired editor login: the uploads come "
					+ "back 401 Unauthorized, one logged error per file above this line. Sign out "
					+ "and back in, or restart the editor, then run this again." );
				return;
			}

			if ( stillMissing > 0 )
			{
				Log.Error( $"[publish] only {toUpload - stillMissing} of {toUpload} files uploaded "
					+ $"- {stillMissing} were rejected, see the errors above. Stopping rather than "
					+ "publishing a package with holes in it." );
				return;
			}

			await publisher.Publish( null, CancellationToken.None );

			// PUBLISH RETURNS A BARE TASK, so "it did not throw" is all it tells us on its own -
			// and a publish that quietly changed nothing looks exactly like one that worked. The
			// backend knows the answer, so ask it: a VersionId that moved is the difference
			// between a new revision and a no-op, and it is the number to quote when somebody
			// says which version broke.
			Log.Info( $"[publish] published {publisher.TargetPackageIdent}" );

			var after = await SettledVersion( publisher.TargetPackageIdent, before );

			if ( before is not null && after is not null && after.VersionId == before.VersionId )
			{
				// EVERY FILE UPLOADED TO GET HERE, so byte-identical content is now the likely
				// reading rather than a guess - but it is still a reading, and this line used to
				// state it as fact about a run where nothing had uploaded at all. The manifest can
				// be refused on its own (`PublishManifest: Unauthorized`), which no count here can
				// see. So say what is known, then point at the log rather than closing the case.
				Log.Warning( $"[publish] version did not move (still {after.VersionId}). All "
					+ $"{toUpload} files uploaded, so this is most likely byte-identical content - "
					+ "the backend accepts that without making a new revision." );
				Log.Warning( "[publish] if that is not what you expected, read the editor log for "
					+ "errors around the publish before running it again." );
			}
			else
			{
				Log.Info( $"[publish] version   {Describe( before )} -> {Describe( after )}" );
			}
		}
		catch ( Exception e )
		{
			// The whole point of the dry run is that a shape change shows up here rather than as a
			// bad version on the backend. Say which step, and leave the wizard as the way through.
			// ToString, not Message: "Object reference not set to an instance of an object" names
			// neither the step nor the cause, and the stack is what turned it into a diagnosis.
			Log.Error( $"[publish] failed: {e}" );
			Log.Error( "[publish] the editor's own Publish dialog still works - use that." );
		}
	}

	/// <summary>
	/// The new revision, once the backend is actually serving it.
	///
	/// Publish returns as soon as the manifest is accepted, and for a moment after that the
	/// backend still answers with the OLD revision. Asking once and reporting the answer said
	/// "version did not move" about a publish that had moved it - the worst kind of wrong, since
	/// the natural next move is to publish again.
	///
	/// So give it a few seconds to settle. Coming back with the old revision after that is a real
	/// answer worth printing; coming back with it immediately never was.
	/// </summary>
	static async Task<Package.IRevision> SettledVersion( string ident, Package.IRevision before )
	{
		Package.IRevision after = null;

		// Thirty seconds, not ten. Ten was measured against a publish that settled quickly and then
		// cried "did not move" about one that took twenty - which is the one report here that must
		// never be wrong, since the obvious response to it is to publish again.
		for ( var attempt = 0; attempt < 30; attempt++ )
		{
			after = await VersionOf( ident );

			if ( before is null || after is null || after.VersionId != before.VersionId )
				return after;

			await Task.Delay( 1000 );
		}

		return after;
	}

	/// <summary>
	/// The revision the backend is currently serving for this package.
	///
	/// `useCache: false` on purpose. The cached copy is whatever this editor last saw, which after
	/// a publish is the version we just replaced - so a cached read would report the publish did
	/// nothing every single time.
	/// </summary>
	static async Task<Package.IRevision> VersionOf( string ident )
	{
		try
		{
			var package = await Package.FetchAsync( ident, false, false );
			return package?.Revision;
		}
		catch ( Exception e )
		{
			Log.Warning( $"[publish] could not read the live version of {ident}: {e.Message}" );
			return null;
		}
	}

	static string Describe( Package.IRevision revision ) =>
		revision is null ? "unknown" : $"v{revision.VersionId} ({revision.FileCount} files, {revision.Created:yyyy-MM-dd HH:mm})";

	/// <summary>
	/// What is actually in the manifest, biggest folder first.
	///
	/// WHY THIS IS NOT OPTIONAL DETAIL. A publish ships what is ON DISK, not what git tracks, and
	/// nothing before this said which files those were - only how many. On 2026-09-10 a dry run
	/// reported "687 total, 231 to upload (459181kb)" and the only clue that 459MB was wrong was
	/// that somebody happened to look at the number. Modelling scratch had been shipping to
	/// everyone who installed the package for revisions, gun models included, and no step in the
	/// process was ever going to mention it: .gitignore does not reach the publisher, and the
	/// publisher does not print names.
	///
	/// FOLDERS, NOT FILES. Four hundred file names is not something anybody reads, and the mistake
	/// this catches is always a whole directory rather than a stray file - a scratch folder inside
	/// Assets, a cache somebody's tool wrote. One line per folder fits on a screen, and the wrong
	/// one stands out by its size.
	///
	/// REFLECTION, DELIBERATELY. ProjectFile is an internal shape on an editor type with no
	/// documented contract, and this is a diagnostic - if a future engine renames its fields, the
	/// right outcome is a breakdown that says "unknown" while the publish still works, not a
	/// publish that will not compile.
	/// </summary>
	static int Breakdown( ProjectPublisher publisher )
	{
		var strays = 0;

		try
		{
			var byFolder = new System.Collections.Generic.Dictionary<string, (int Count, long Bytes)>();
			var strayBytes = 0L;

			foreach ( var file in publisher.Files )
			{
				var type = file.GetType();

				var path = type.GetProperty( "Name" )?.GetValue( file ) as string
					?? type.GetProperty( "Path" )?.GetValue( file ) as string
					?? type.GetProperty( "RelativePath" )?.GetValue( file ) as string
					?? "unknown";

				long bytes = 0;

				foreach ( var name in new[] { "Size", "Length", "FileSize" } )
				{
					if ( type.GetProperty( name )?.GetValue( file ) is { } value )
					{
						bytes = Convert.ToInt64( value );
						break;
					}
				}

				// The first two segments, so `assets/models/effigy/x.vmdl` groups under
				// `assets/models` rather than under `assets` with everything else in the project.
				var parts = path.Replace( '\\', '/' ).Split( '/' );
				var folder = parts.Length switch
				{
					<= 1 => "(root)",
					2 => parts[0],
					_ => $"{parts[0]}/{parts[1]}",
				};

				var current = byFolder.TryGetValue( folder, out var had ) ? had : (Count: 0, Bytes: 0L);

				byFolder[folder] = (current.Count + 1, current.Bytes + bytes);

				if ( IsStray( path ) )
				{
					strays++;
					strayBytes += bytes;
				}
			}

			Log.Info( "[publish] contents  (biggest first)" );

			foreach ( var entry in byFolder.OrderByDescending( e => e.Value.Bytes ).Take( 15 ) )
			{
				Log.Info( $"[publish]   {entry.Value.Bytes / 1024,10:n0} kb  "
					+ $"{entry.Value.Count,5} files  {entry.Key}"
					+ (IsStray( entry.Key + "/" ) ? "   <-- STRAY" : "") );
			}

			if ( strays > 0 )
			{
				Log.Warning( $"[publish] STRAY     {strays} file(s), {strayBytes / 1024:n0} kb under "
					+ "Assets/ that are not this package's content - modelling scratch, test exports, "
					+ "whatever the editor last saved. Move them out of the project directory." );
			}
		}
		catch ( Exception e )
		{
			// Never take the publish down over a diagnostic - but a breakdown that failed has said
			// nothing about strays either, so it must not report zero of them and wave a publish
			// through on that.
			Log.Warning( $"[publish] contents  could not be listed ({e.Message})" );
			return 0;
		}

		return strays;
	}

	/// <summary>
	/// Whether a manifest path is content this package has no business shipping.
	///
	/// ASSETS ONLY, AND ONE EXCEPTION. The manifest also carries the source tree - `Effigy/`,
	/// `Editor/`, `Effigy.Tests/` - and for a LIBRARY package that source IS the deliverable, so
	/// none of it is stray however untidy it looks. What is never the deliverable is a part studio
	/// somebody saved while testing, a 300MB folder of compiled model experiments, or the scene the
	/// editor last wrote. All of those land under `Assets/`.
	///
	/// The exception is `Assets/editor/`, which holds the tool's own icon and is what the .sbproj's
	/// `Resources` line means to ship.
	///
	/// WHY A PATH RULE AND NOT A SIZE LIMIT. A size limit needs a number, and any number is either
	/// so high it passes the thing it exists to catch or so low it fires on a legitimate asset
	/// somebody adds next year. Whether a file belongs in the package does not depend on how big it
	/// is - 460MB of model exports and one stray `untitled.effigy` are the same mistake.
	/// </summary>
	static bool IsStray( string path )
	{
		var p = path.Replace( '\\', '/' );

		if ( !p.StartsWith( "assets/", StringComparison.OrdinalIgnoreCase ) )
			return false;

		return !p.StartsWith( "assets/editor/", StringComparison.OrdinalIgnoreCase );
	}

	/// <summary>
	/// The last commit's subject and body, which is what the change notes should say.
	///
	/// Shelling out to git rather than parsing .git ourselves: the repo is right there and git is
	/// the thing that knows how to read it. If git is missing or this is not a checkout, the date
	/// fallback is what the wizard would have offered anyway, so nothing is worse than before.
	/// </summary>
	static (string Title, string Detail) ChangeNotes( string root )
	{
		var subject = Git( root, "log -1 --pretty=%s" );
		var body = Git( root, "log -1 --pretty=%b" );

		if ( string.IsNullOrWhiteSpace( subject ) )
			return ($"Changes on {DateTime.Now:yyyy-MM-dd}", "");

		return (subject.Trim(), WithoutTrailers( body ));
	}

	/// <summary>
	/// The commit body without its trailer block - Co-Authored-By, Signed-off-by and friends.
	///
	/// Those are addressed to the repository, not to somebody reading a package's release notes,
	/// and they sit at the end where they are easy to drop. Only a run of trailers at the very END
	/// goes: a line shaped like "Note: something" in the middle of a paragraph is prose.
	/// </summary>
	static string WithoutTrailers( string body )
	{
		var lines = (body ?? "").TrimEnd().Split( '\n' );
		var end = lines.Length;

		while ( end > 0 )
		{
			var line = lines[end - 1].Trim();

			if ( line.Length == 0 )
			{
				end--;
				continue;
			}

			var colon = line.IndexOf( ':' );

			if ( colon <= 0 || line.Contains( ' ' ) && line.IndexOf( ' ' ) < colon )
				break;

			end--;
		}

		return string.Join( "\n", lines[..end] ).Trim();
	}

	static string Git( string root, string arguments )
	{
		try
		{
			using var p = Process.Start( new ProcessStartInfo( "git", arguments )
			{
				WorkingDirectory = root,
				RedirectStandardOutput = true,
				RedirectStandardError = true,
				UseShellExecute = false,
				CreateNoWindow = true,
			} );

			if ( p is null )
				return null;

			var output = p.StandardOutput.ReadToEnd();
			p.WaitForExit( 5000 );

			return p.ExitCode == 0 ? output : null;
		}
		catch
		{
			return null;
		}
	}
}