#!/usr/bin/perl -w
###############################################################################
#	Copyright 2026 BITart Gerd Knops,  All rights reserved.
#
#	Project	: GerdsToolSet - Xcode - buildScripts
#	File	: updateSite
#	Author	: Gerd Knops gerti@BITart.com
#
###############################################################################
#
#	History:
#	260425 Creation of file
#
###############################################################################
#
#	Description:
#	Update local site directory with app release. Creates/updates:
#	  - Landing page (index.html)
#	  - Release history (history.html)
#	  - Appcast for Sparkle (appcast.xml)
#	  - Version directory with zip and notes
#
#	Required environment variables:
#	  PRODUCT_NAME, MARKETING_VERSION, GTS_APP_NAME, GTS_TAG_LINE,
#	  GTS_ACCENT_COLOR, GTS_COPYRIGHT, PROJECT_DIR, GTS_SITE_DIR,
#	  GTS_SITE_URL
#
#	Optional:
#	  GTS_MIN_MACOS     - Minimum macOS version (default: 14.0)
#	  GTS_HOME_NAME     - Brand name shown in site footer (default: BITart)
#	  GTS_HOME_URL      - Brand home URL shown in site footer
#	                      (default: https://www.bitart.com/)
#
###############################################################################
# Configuration
###############################################################################

	use strict;
	use File::Basename;
	use File::Copy;
	use File::Path qw(make_path);
	use Cwd qw(abs_path);

	use FindBin;
	use lib "$FindBin::RealBin/../perl";

	# Argument-free invocation: re-render the site against the archive most
	# recently produced by postArchiveHook. Reads the saved env file, populates
	# %ENV (without clobbering anything already set), and synthesizes @ARGV from
	# the persisted ZIP_PATH / ICON_PATH. This must run before the PROJECT_DIR
	# BEGIN below since that block dies on missing PROJECT_DIR.
	BEGIN {
		if(!@ARGV)
		{
			# Locate the saved env file from the most recent archive. We don't
			# touch the project tree on principle (build artifacts belong in
			# Xcode's DerivedData), so discovery falls back to a glob across
			# DerivedData scoped to this project's xcodeproj name.
			my @candidates;
			push(@candidates,$ENV{GTS_ARCHIVE_ENV_FILE}) if($ENV{GTS_ARCHIVE_ENV_FILE});
			push(@candidates,"$ENV{BUILD_DIR}/Release/lastArchiveEnv.sh") if($ENV{BUILD_DIR});

			# Project root is three levels above buildScripts/. Glob the
			# expected DerivedData layout for this project, newest first.
			my $projectDir=abs_path("$FindBin::RealBin/../../..");
			my $dh;
			if($projectDir && -d $projectDir && opendir($dh,$projectDir))
			{
				my ($xcodeproj)=grep { /\.xcodeproj$/ } readdir($dh);
				closedir($dh);

				if($xcodeproj)
				{
					(my $stem=$xcodeproj)=~s/\.xcodeproj$//;

					# Honor IDECustomDerivedDataLocation: users can relocate
					# DerivedData via Xcode prefs, in which case the default
					# path under ~/Library/... won't exist. Try the custom
					# location first, then the default — keeping both lets a
					# stale preference value coexist with a real default-
					# location archive.
					my @roots;
					my $custom=`defaults read com.apple.dt.Xcode IDECustomDerivedDataLocation 2>/dev/null`;
					chomp($custom);
					push(@roots,$custom) if($custom && -d $custom);
					my $default="$ENV{HOME}/Library/Developer/Xcode/DerivedData";
					push(@roots,$default) if(-d $default && !grep { $_ eq $default } @roots);

					# postArchiveHook writes to $BUILD_DIR/Release/, where
					# during archive $BUILD_DIR is the archive's intermediate
					# BuildProductsPath — not the regular Build/Products
					# tree. The latter is kept as a fallback.
					foreach my $derived (@roots)
					{
						foreach my $tail (
							'Build/Intermediates.noindex/ArchiveIntermediates/*/BuildProductsPath/Release/lastArchiveEnv.sh',
							'Build/Products/Release/lastArchiveEnv.sh',
						)
						{
							my @hits=sort { (stat($b))[9] <=> (stat($a))[9] } glob("$derived/$stem-*/$tail");
							push(@candidates,@hits);
						}
					}
				}
			}

			my $loaded;
			foreach my $file (@candidates)
			{
				next unless($file && -f $file);

				open(my $fh,'<',$file) or next;
				while(my $line=<$fh>)
				{
					next if($line=~/^\s*(?:#|$)/);
					next unless($line=~/^\s*export\s+(\w+)=(?:"([^"]*)"|'([^']*)'|(\S*))\s*$/);
					my $key=$1;
					my $val=defined($2) ? $2 : (defined($3) ? $3 : $4);
					$ENV{$key}=$val unless(defined($ENV{$key}) && length($ENV{$key}));
				}
				close($fh);

				$loaded=$file;
				last;
			}

			if($loaded && $ENV{ZIP_PATH} && $ENV{ICON_PATH})
			{
				print "Reusing archive state from: $loaded\n";
				@ARGV=($ENV{ZIP_PATH},$ENV{ICON_PATH});
			}
		}
	}

	# postArchiveHook launches us from a fresh Terminal whose CWD is $HOME, not
	# a git repo. GTS::ReleaseNotes calls GTS::Git::mineGit at module-load time,
	# so the project root must be the CWD before that `use` runs — otherwise
	# mineGit's no-tag fallback invents a "yy.0" tag from the current year.
	BEGIN {
		my $dir=$ENV{PROJECT_DIR} or die("PROJECT_DIR not set\n");
		chdir($dir) or die("Can't chdir to '$dir': $!\n");
	}

	use GTS::ReleaseNotes;
	use GTS::BuildScriptUtilities;

###############################################################################
# Globals
###############################################################################

	my $ProductName=$ENV{PRODUCT_NAME} or die("PRODUCT_NAME not set\n");
	my $ProductSlug=lc($ProductName);
	my $Version=$ENV{MARKETING_VERSION} or die("MARKETING_VERSION not set\n");
	# Use || (not //) for env-var fallbacks: postArchiveHook re-exports
	# every GTS_* var verbatim, so unset xcconfig keys arrive as defined-but-empty
	# strings — // would treat those as set and skip the default.
	my $AppName=$ENV{GTS_APP_NAME} || $ProductName;
	my $TagLine=$ENV{GTS_TAG_LINE} || '';
	my $AccentColor=$ENV{GTS_ACCENT_COLOR} || 'orange';
	my $Copyright=$ENV{GTS_COPYRIGHT} || '';
	my $ProjectDir=$ENV{PROJECT_DIR} or die("PROJECT_DIR not set\n");
	my $MinMacOS=$ENV{GTS_MIN_MACOS} || '14.0';
	my $HomeName=$ENV{GTS_HOME_NAME} || 'BITart';
	my $HomeURL=$ENV{GTS_HOME_URL} || 'https://www.bitart.com/';

	my $SiteURL=$ENV{GTS_SITE_URL} or die("GTS_SITE_URL not set\n");
	my $SiteDir=$ENV{GTS_SITE_DIR} or die("GTS_SITE_DIR not set\n");
	my $SiteRoot=dirname($SiteDir);

	my $IsDev=isDev($Version);

	my $OfficialAppDir="$SiteDir/$ProductSlug";
	my $DevAppDir="$OfficialAppDir/dev";
	my $OfficialReleasesDir="$OfficialAppDir/releases";
	my $DevReleasesDir="$DevAppDir/releases";

	# Active-train globals (mutated by refreshDev after an official publish)
	my $AppSiteDir=$IsDev ? $DevAppDir : $OfficialAppDir;
	my $ReleasesDir="$AppSiteDir/releases";
	my $VersionDir="$ReleasesDir/$Version";
	my $AppURLPath=$IsDev ? "$ProductSlug/dev" : "$ProductSlug";

	my $TemplateDir="$FindBin::RealBin/../../Site/templates";

	my $ZipPath=$ARGV[0];
	my $IconPath=$ARGV[1];

###############################################################################
# Main
###############################################################################

	die("Usage: updateSite <zip-path> <icon-path>\n") unless($ZipPath && $IconPath);
	die("Zip not found: $ZipPath\n") unless(-f $ZipPath);
	die("Icon not found: $IconPath\n") unless(-f $IconPath);

	my $trainLabel=$IsDev ? 'dev' : 'official';

	print "\n";
	print "=============================================\n";
	print "  Publishing $AppName $Version ($trainLabel)\n";
	print "=============================================\n";
	print "\n";

	publishCurrentTrain();

	if(!$IsDev)
	{
		purgeOldDevReleases($Version);
		refreshDev();
	}

	print "\n";
	print "=============================================\n";
	print "  Done\n";
	print "=============================================\n";
	print "\n";

	offerPublish();

###############################################################################
# Subroutines
###############################################################################
sub checkForWithdrawn {

	if(-f "$VersionDir/.withdrawn")
	{
		die("Version $Version was previously withdrawn. Choose a new version number.\n");
	}
}
sub offerPublish {

	my $publishScript="$SiteRoot/publishSite";

	unless(-x $publishScript)
	{
		print "No publishSite script found at: $publishScript\n";
		print "\n";
		return;
	}

	print "\n";
	system($publishScript)==0 or warn("publishSite failed: $?\n");
}
sub createDirectories {

	print "==> Creating directories...\n";

	make_path($VersionDir) unless(-d $VersionDir);
}
sub copyAssets {

	print "==> Copying assets...\n";

	my $zipName=basename($ZipPath);
	copy($ZipPath,"$VersionDir/$zipName") or die("Failed to copy zip: $!\n");
	copy($IconPath,"$AppSiteDir/icon_256x256.png") or die("Failed to copy icon: $!\n");
}
sub processSiteAssets {

	# Walk the project's Site/ directory and propagate every file to the
	# active train's site dir. *.md files are rendered through the landing
	# template (so index.md → index.html and any other foo.md → foo.html
	# share consistent chrome). All other files are copied verbatim, which
	# covers images and any other assets the markdown references.
	my $srcDir="$ProjectDir/$ProductName/Site";

	return unless(-d $srcDir);

	opendir(my $dh,$srcDir) or die("Can't open '$srcDir': $!\n");
	my @files=grep { -f "$srcDir/$_" && !/^\./ } readdir($dh);
	closedir($dh);

	return unless(@files);

	print "==> Processing site assets...\n";

	foreach my $name (@files)
	{
		my $src="$srcDir/$name";
		if($name=~/\.md$/)
		{
			(my $out=$name)=~s/\.md$/.html/;
			renderMarkdownPage($src,"$AppSiteDir/$out");
		}
		else
		{
			copy($src,"$AppSiteDir/$name") or die("Failed to copy $name: $!\n");
		}
	}
}
sub renderMarkdownPage {

	my $srcPath=shift;
	my $dstPath=shift;

	my $description=md2html(readFile($srcPath));

	writeTemplateFromFile(
		"$TemplateDir/app-landing.html",
		$dstPath,
		{
			AppName     => $AppName,
			TagLine     => $TagLine,
			AccentColor => $AccentColor,
			Copyright   => $Copyright,
			HomeName    => $HomeName,
			HomeURL     => $HomeURL,
			Description => $description,
		}
	);
}
sub generateDownloadPage {

	print "==> Generating download page...\n";

	# Show what changed since the previous major. Main releases coalesce all
	# point releases into one set of typed sections; dev releases keep the
	# per-point-release breakdown so testers can see what landed in each tag.
	# In dev mode the markdown emits `## tag` / `### type` headings, which we
	# bump down one level so they nest under the template's "What's New" h2.
	my $recentNotesMD=GTS::ReleaseNotes::extractReleaseNotes(
		stopAtMajor => 1,
		coalesce    => !$IsDev,
	);
	$recentNotesMD=~s/^(#+) /$1# /gm if($IsDev);
	my $recentNotes=md2html($recentNotesMD);

	# Dev download surfaces whichever train has the highest version (so dev users
	# see the new official immediately after a cross-train publish). Official
	# download always advertises the version we just published.
	my ($latestVer,$latestURL)=$IsDev
		? latestVersionAcrossTrains()
		: ($Version,"releases/$Version/$ProductName-$Version.zip");

	writeTemplateFromFile(
		"$TemplateDir/app-download.html",
		"$AppSiteDir/download.html",
		{
			AppName         => $AppName,
			AccentColor     => $AccentColor,
			Copyright       => $Copyright,
			HomeName        => $HomeName,
			HomeURL         => $HomeURL,
			Version         => $latestVer,
			MinMacOSVersion => $MinMacOS,
			DownloadURL     => $latestURL,
			RecentNotes     => $recentNotes,
		}
	);
}
sub generateHistoryPage {

	print "==> Generating history page...\n";

	my $allNotesMD=GTS::ReleaseNotes::extractReleaseNotes();
	my $allNotes=md2html($allNotesMD);

	writeTemplateFromFile(
		"$TemplateDir/history.html",
		"$AppSiteDir/history.html",
		{
			AppName     => $AppName,
			AccentColor => $AccentColor,
			Copyright   => $Copyright,
			HomeName    => $HomeName,
			HomeURL     => $HomeURL,
			AllNotes    => $allNotes,
		}
	);
}
sub generateAppcast {

	print "==> Generating appcast for $AppURLPath...\n";

	my $appcastPath="$AppSiteDir/appcast.xml";
	my $appcastURL="$SiteURL/$AppURLPath/appcast.xml";

	# Active train always feeds itself; dev train ALSO mixes in official releases
	# so subscribers see the latest of either.
	my @sources=([$ReleasesDir,"$SiteURL/$AppURLPath/releases"]);

	if($IsDev)
	{
		push(@sources,[$OfficialReleasesDir,"$SiteURL/$ProductSlug/releases"]);
	}

	my $items=generateAppcastItems(@sources);

	writeTemplateFromFile(
		"$TemplateDir/appcast.xml",
		$appcastPath,
		{
			AppName    => $AppName,
			AppcastURL => $appcastURL,
			Items      => $items,
		}
	);
}
sub generateAppcastItems {

	my @sources=@_;

	my @entries=();

	foreach my $src (@sources)
	{
		my ($dir,$urlPrefix)=@$src;

		opendir(my $dh,$dir) or next;
		my @vers=grep { -d "$dir/$_" && !/^\./ } readdir($dh);
		closedir($dh);

		foreach my $ver (@vers)
		{
			my $verDir="$dir/$ver";

			next if(-f "$verDir/.withdrawn");

			my $zipName="$ProductName-$ver.zip";
			my $zipPath="$verDir/$zipName";

			next unless(-f $zipPath);

			my $notesPath="$verDir/releaseNotes.html";
			my $notesURL=(-f $notesPath) ? "$urlPrefix/$ver/releaseNotes.html" : '';

			push(@entries,{
				version     => $ver,
				zipName     => $zipName,
				zipPath     => $zipPath,
				downloadURL => "$urlPrefix/$ver/$zipName",
				notesURL    => $notesURL,
			});
		}
	}

	@entries=sort { versionCompare($b->{version},$a->{version}) } @entries;

	my $items='';

	foreach my $e (@entries)
	{
		my $zipSize=-s $e->{zipPath};
		my $pubDate=getPubDate($e->{zipPath});

		my $sig=getSparkleSignature($e->{zipPath});
		my $sigAttr=$sig ? qq{\n                       sparkle:edSignature="$sig"} : '';

		my $notesElem=$e->{notesURL}
			? "            <sparkle:releaseNotesLink>$e->{notesURL}</sparkle:releaseNotesLink>\n"
			: '';

		$items.=<<"EOF";
        <item>
            <title>Version $e->{version}</title>
            <pubDate>$pubDate</pubDate>
            <sparkle:version>$e->{version}</sparkle:version>
            <sparkle:shortVersionString>$e->{version}</sparkle:shortVersionString>
$notesElem            <enclosure url="$e->{downloadURL}"
                       length="$zipSize"
                       type="application/octet-stream"$sigAttr/>
        </item>
EOF
	}

	$items;
}
sub publishCurrentTrain {

	checkForWithdrawn();
	createDirectories();
	copyAssets();
	processSiteAssets();
	generateReleaseNotes();
	generateDownloadPage();
	generateHistoryPage();
	generateAppcast();
}
sub generateReleaseNotes {

	# Per-version notes file linked from the appcast via
	# <sparkle:releaseNotesLink>. Sparkle renders the HTML inline in its
	# update dialog. Same scope as the download page: everything since the
	# previous major, coalesced for official, per-tag for dev.
	print "==> Generating release notes...\n";

	my $notesMD=GTS::ReleaseNotes::extractReleaseNotes(
		stopAtMajor => 1,
		coalesce    => !$IsDev,
	);
	$notesMD=~s/^(#+) /$1# /gm if($IsDev);
	my $notes=md2html($notesMD);

	writeTemplateFromFile(
		"$TemplateDir/release-notes.html",
		"$VersionDir/releaseNotes.html",
		{
			AppName     => $AppName,
			AccentColor => $AccentColor,
			Version     => $Version,
			Notes       => $notes,
		}
	);
}
sub refreshDev {

	return unless(-d $DevAppDir);

	print "==> Refreshing dev train\n";

	# Switch active-train state to dev, then re-run the artifacts that depend
	# on it. Skip checkForWithdrawn / createDirectories / copyAssets — those
	# are publish-time concerns and we're not publishing a new dev version.
	$AppSiteDir   =$DevAppDir;
	$ReleasesDir  =$DevReleasesDir;
	$AppURLPath   ="$ProductSlug/dev";
	$IsDev        =1;

	processSiteAssets();
	generateDownloadPage();
	generateHistoryPage();
	generateAppcast();
}
sub purgeOldDevReleases {

	my $upToVersion=shift;

	return unless(-d $DevReleasesDir);

	opendir(my $dh,$DevReleasesDir) or return;
	my @vers=grep { -d "$DevReleasesDir/$_" && !/^\./ } readdir($dh);
	closedir($dh);

	foreach my $ver (@vers)
	{
		if(versionCompare($ver,$upToVersion)<=0)
		{
			print "==> Removing obsolete dev release: $ver\n";
			system("rm","-rf","$DevReleasesDir/$ver");
		}
	}
}
sub latestVersionAcrossTrains {

	my @candidates=();

	foreach my $tuple (
		[$DevReleasesDir,     "$SiteURL/$ProductSlug/dev/releases"],
		[$OfficialReleasesDir,"$SiteURL/$ProductSlug/releases"],
	)
	{
		my ($dir,$urlPrefix)=@$tuple;

		opendir(my $dh,$dir) or next;
		my @vers=grep { -d "$dir/$_" && !/^\./ } readdir($dh);
		closedir($dh);

		foreach my $ver (@vers)
		{
			next if(-f "$dir/$ver/.withdrawn");

			my $zipName="$ProductName-$ver.zip";
			my $zipPath="$dir/$ver/$zipName";

			next unless(-f $zipPath);

			push(@candidates,{
				version     => $ver,
				downloadURL => "$urlPrefix/$ver/$zipName",
			});
		}
	}

	@candidates=sort { versionCompare($b->{version},$a->{version}) } @candidates;

	# Fall back to the version we're publishing if we somehow haven't placed
	# it yet (shouldn't happen since copyAssets runs first).
	return ($Version,"releases/$Version/$ProductName-$Version.zip") unless(@candidates);

	($candidates[0]->{version},$candidates[0]->{downloadURL});
}
sub isDev {

	my $version=shift;

	$version=~/^\d+\.\d+\.[1-9]/;
}
sub versionCompare {

	my $a=shift;
	my $b=shift;

	my @a=split(/\./,$a);
	my @b=split(/\./,$b);

	my $max=@a > @b ? @a : @b;

	for(my $i=0; $i<$max; $i++)
	{
		my $va=$a[$i] // 0;
		my $vb=$b[$i] // 0;

		return $va <=> $vb if($va != $vb);
	}

	0;
}
sub getPubDate {

	my $path=shift;

	my @stat=stat($path);
	my @t=gmtime($stat[9]);

	my @days=qw(Sun Mon Tue Wed Thu Fri Sat);
	my @months=qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);

	sprintf("%s, %02d %s %04d %02d:%02d:%02d +0000",
		$days[$t[6]], $t[3], $months[$t[4]], $t[5]+1900, $t[2], $t[1], $t[0]);
}
sub getSparkleSignature {

	my $zipPath=shift;

	my $signUpdate="$FindBin::RealBin/../../Sparkle/bin/sign_update";

	unless(-x $signUpdate)
	{
		warn("sign_update not found at $signUpdate, skipping Sparkle signature\n");
		return '';
	}

	my $keyFile=$ENV{SPARKLE_KEY_FILE};

	my $cmd=($keyFile && -f $keyFile)
		? qq{"$signUpdate" "$zipPath" -f "$keyFile"}
		: qq{"$signUpdate" "$zipPath"};

	my $out=`$cmd 2>/dev/null`;
	chomp($out);

	# sign_update prints a snippet like:
	#   sparkle:edSignature="…" length="…"
	# meant to be pasted as enclosure attributes. We only want the signature
	# value here — the caller emits the attribute name and computes length
	# separately.
	return $1 if($out=~/sparkle:edSignature="([^"]+)"/);

	warn("Unexpected sign_update output, skipping Sparkle signature: $out\n");

	'';
}
sub writeTemplateFromFile {

	my $templatePath=shift;
	my $dstPath=shift;
	my $vars=shift;

	my $template=readFile($templatePath);

	foreach my $key (keys %$vars)
	{
		$template=~s/~~$key~~/$vars->{$key}/g;
	}

	open(my $fh,'>',$dstPath) or die("Can't open '$dstPath' for write: $!\n");
	print $fh $template;
	close($fh);
}

1;
###############################################################################
