$ErrorActionPreference = 'Stop';
Set-StrictMode -Version '2.0'

$commonScriptDir = (Split-Path $script:MyInvocation.MyCommand.Path);

Import-Module (Join-Path $commonScriptDir 'Hash.psm1')

<#
.Synopsis
Create a fileset that allows for manipulation of trees of files
that are to be copied to a destination.

.Description
Create a fileset. This is a set of files that are rooted from a
source folder, are to be copied to a destination folder,
and those files that would be overwritten are to be backed-up
to a backup folder.

A number of operations are able to be done subsequently on a fileset
after it's created.

.Parameter SourceFolderPath
The folder (relative to files in the hotfix folder) that holds the contents
of the hotfix.

.Parameter DestinationFolderPath
The full path of the location all files under $SourceFolderPath will
be copied.

.Parameter Transform
Indicates that this is a transform fileset. All files with the extension
'.xdt' will be run as a transform against a file in the destination folder
with the same name (minus '.xdt'). Thus log.config.xdt will backup and
transform the file log.config in the destination.

.Parameter SourceFilter
A wildcard filename filter that will restrict the files included in the fileset when
the source location is scanned.

.Parameter BasePath
The path under which the 'files' and 'backup' base folders are found. Defaults to '.'.
#>
function New-FileSet {
	[CmdletBinding()]
	param (
		[Parameter(Mandatory = $true)]
		[string]$SourceFolderPath,

		[Parameter(Mandatory = $true)]
		[string]$DestinationFolderPath,
		
		[switch]$Transform = $true,

		[string]$SourceFilter = '*',

		[string]$BasePath = '.'
	)
	
	if (-not (Test-Path $BasePath)) {
		throw "Supplied BasePath does not exist: $BasePath"
	}

	# ensure that the base path is absolute
	$BasePath = (Resolve-Path $BasePath).Path;
	
	$sourceBasePath = Join-Path $BasePath 'files'
	$backupBasePath = Join-Path $BasePath 'backup'

	$fileSetSourceFolderPath = Join-Path $sourceBasePath $SourceFolderPath
	
	if (-not (Test-Path $BasePath)) {
		throw "Supplied BasePath does not exist: $BasePath"
	}

	if (-not (Test-Path $fileSetSourceFolderPath)) {
		throw "The fileset source folder doesn't exist, no files can be copied. $fileSetSourceFolderPath"
	}

	if (-not (Test-Path $backupBasePath)) {
		Write-Host "Creating backup folder $backupBasePath";
		mkdir $backupBasePath | Out-Null
	}

	if (-not (Test-Path $DestinationFolderPath)) {
		Write-Host "Creating destination folder $DestinationFolderPath";
		mkdir $DestinationFolderPath | Out-Null
	}
	
	$fileSetDestinationFolderPath = (Resolve-Path $DestinationFolderPath).Path
	$fileSetBackupFolderPath = Get-BackupPath $backupBasePath $fileSetDestinationFolderPath

	$info = New-Object PSObject -Property  @{
		BasePath = $BasePath;
		SourceFolderPath = $fileSetSourceFolderPath;
		BackupFolderPath = $fileSetBackupFolderPath;
		BackupManifestPath = (($fileSetBackupFolderPath -replace '\\$','') + '.manifest')
		DestinationFolderPath = $fileSetDestinationFolderPath;
		
		Files = (Get-FileSetSourceTree $fileSetSourceFolderPath $SourceFilter);
		FileDetails = $null;
		DestinationFiles = $null;
	};
	
	$info.FileDetails = @(
		$info.Files |
		ForEach-Object { 
			New-Object PSObject -Property  @{ 
				Path = $_;
				DestSubPath = $_;
				Source = (Join-Path $info.SourceFolderPath $_);
				Dest = (Join-Path $info.DestinationFolderPath $_);
				Backup = (Join-Path $info.BackupFolderPath $_);
				Transform = $false;
			}
		});
	
	if ($Transform) {
		$info.FileDetails |
			Where-Object { $_.Path -ilike '*.xdt' } |
			ForEach-Object {
				$_.Transform = $true;
				$_.DestSubPath = $_.DestSubPath -replace '\.xdt$','';
				$_.Dest = $_.Dest -replace '\.xdt$','';
				$_.Backup = $_.Backup -replace '\.xdt$','';
			}
	}
	
	$info.DestinationFiles = @(
		$info.FileDetails | 
		Select-Object -ExpandProperty Dest |
		Where-Object { Test-Path $_ });
	
	return $info;
}

function Test-FileSetHasSources {
	[CmdletBinding()]
	param(
		[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
		[PSObject]$fs
	)
	
	begin {
		$hasSources = $false;
	}
	
	process {
		if (-not $hasSources) {
			$hasSources = $fs.Files.Length -gt 0;
		}
	}
	
	end {
		return $hasSources
	}
}

function Test-FileSetHasBackup {
	[CmdletBinding()]
	param(
		[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
		[PSObject]$fs
	)
	
	begin {
		$hasBackup = $false;
	}
	
	process {
		if (-not $hasBackup) {
			$hasBackup = (Test-Path $fs.BackupFolderPath) -and (@(Get-ChildItem -Path $fs.BackupFolderPath).Length -gt 0);
		}
	}
	
	end {
		return $hasBackup
	}
}

<#
.Synopsis
Copy files that will be overwritten by the hotfix from the destination path
to a corresponding path in the backup location.

.Description
Copied files will preserve their tree structure, and the backup location will mirror
that. Files will only be backed up once, unless the Force parameter is specified.

.Parameter Force
Ensure that all files that are to be copied over are copied from the destination
to the backup location, regardless of whether a file already exists in the backup
location.
#>
function Backup-FileSet {
	[CmdletBinding()]
	param(
		[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
		[PSObject]$fs,
		[switch]$Force
	)
	
	process {
		
		if (-not (Test-Path $fs.BackupFolderPath)) {
			Write-Host "Creating backup folder $($fs.BackupFolderPath)";
			mkdir $fs.BackupFolderPath | Out-Null;
		}
		
		# if backup exists, don't backup again unless forced
		if (Test-Path $fs.BackupManifestPath) {
			Write-Debug "File set already backed up, skipping backup of $($fs.DestinationFolderPath)"

			return;
		}

		# unless forced, filter out files that already exist in backup location
		$backupSet = @( 
			$fs.FileDetails |
			Where-Object { (Test-Path $_.Dest) -and ($Force -or -not (Test-Path $_.Backup)) });
		
		if ($backupSet.Length -gt 0) {

			# create manifest
			$manifestContent = @(
				$backupSet | 
				Select-Object -ExpandProperty Dest | 
				Get-FileHashCustom |
				ForEach-Object { 
					$manifestEntry = $_ | Add-Member `
						-MemberType 'NoteProperty' -Name 'SubPath' `
						-Value (Remove-BasePath -BasePath $fs.DestinationFolderPath $_.Path) `
						-PassThru
					
					return $manifestEntry
				})

			$manifestContent | Export-Csv -Path $fs.BackupManifestPath -Encoding 'utf8'

			# copy files
			
			$backupSet |
				Resolve-AllFileParentPaths -PropertyName 'Backup' |
				ForEach-Object {
					Copy-Item `
						-LiteralPath $_.Dest `
						-Destination $_.Backup `
						-PassThru
				} |
				Select-Object -ExpandProperty FullName |
				Write-Host;
		}
	}
}

function Restore-FileSet {
	[CmdletBinding()]
	param(
		[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
		[PSObject]$fs
	)

	# There are two stages: verify and commit. The verify
	# stage acts on the pipeline, keeping both the set of restore
	# problems and the set of actions required. At the end of pipeline
	# execution, if no issues have been detected, the actions are executed.
	
	begin {

		$mismatchedRestoreFiles = @()
		$missingRestoreFiles = @()
		$mismatchedDeleteFiles = @()

		$overallRestoreActions = @()
		$overallDeleteActions = @()

		$processedFilesets = @()
	}

	process {
		# if no backup or backup manifest exists, fail
		if (-not (Test-Path $fs.BackupFolderPath)) {
			throw "Backup folder for fileset does not exist: $($fs.BackupFolderPath)"
		}

		if (-not (Test-Path $fs.BackupManifestPath)) {
			throw "Backup manifest for fileset does not exist: $($fs.BackupManifestPath)"
		}
		
		# read backup manifest - can't use Import-Csv as it does not have an Encoding parameter in PS 2.0
		$manifestEntries = [System.IO.File]::ReadAllText($fs.BackupManifestPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Csv

		$missingBackups = @($manifestEntries | ForEach-Object { (Join-Path $fs.BackupFolderPath $_.SubPath) } | Where-Object { -not (Test-Path $_)});
		if ($missingBackups.Length -gt 0) {
			throw "Some files present in backup manifest do not exist in backup folder:`r`n$($missingBackups -join "`r`n")"
		}

		$manifestEntriesBySubPath = @{};
		foreach ($entry in $manifestEntries) {
			$manifestEntriesBySubPath[$entry.SubPath] = $entry;
		}

		# calculate restore set (files overwritten by fileset)
		# sub-paths that exist in the manifest
		$restoreFiles = @(
			$fs.FileDetails |
			Where-Object { $manifestEntriesBySubPath.ContainsKey($_.DestSubPath) }
		)

		# calculate delete set (files added by fileset)
		# sub-paths that exist in the destination but not the manifest
		$deleteFiles = @(
			$fs.FileDetails | 
			Where-Object { -not $manifestEntriesBySubPath.ContainsKey($_.DestSubPath) }
		)


		# each destination file in the restore set must exist and match the hash of either a backup or the source file
		foreach ($rf in $restoreFiles) {

			if (Test-Path $rf.Dest) {
				$backupHash = $manifestEntriesBySubPath[$rf.DestSubPath]
				$destHash = Get-FileHashCustom $rf.Dest

				if ($destHash.Hash -ne $backupHash.Hash) { # hash difference from backup means modified after backup
					# check if it matches the source
					$sourceDigest = $null;
					if ($rf.Transform) {
						# after a transform occurs, it writes a digest to the backup location
						# this is the 'after state' of the transformation
						# if the backup hash doesn't match, it needs to match the digest
						# no digest = transform not applied
						if (Test-BackupDigest $rf) {
							$sourceDigest = Read-BackupDigest $rf
						}
					} else {
						$sourceHash = Get-FileHashCustom $rf.Source
						$sourceDigest = $sourceHash.Hash
					}

					if ($sourceDigest -and $destHash.Hash -ne $sourceDigest) {
						$mismatchedRestoreFiles += $rf
					} else {
						# all ok, file has changed since backup, restore required
						$overallRestoreActions += $rf;
					}
				} else {
					# When the destination matches the backup, the destination file is unchanged. No action is required.
					# Avoiding this attempts to minimise the copy operations that can fail in the presence of a file lock.
					Write-Debug "Skipping restore of file as current version in destination matches backup: $($rf.Dest)"
				}
			} else {
				$missingRestoreFiles += $rf
			}
		}

		# each destination file in the delete set and match the hash of the original file
		foreach ($df in $deleteFiles) {

			# only test hash if it exists in the destination: the fileset may not have been fully copied
			if (Test-Path $df.Dest) {
				$sourceHash = Get-FileHashCustom $df.Source
				$destHash = Get-FileHashCustom $df.Dest

				if ($destHash.Hash -ne $sourceHash.Hash) {
					$mismatchedDeleteFiles += $df
				} else {
					$overallDeleteActions += $df
				}
			}
		}

		$processedFilesets += $fs
	}


	end {
		if ($mismatchedRestoreFiles.Length -gt 0 -or $missingRestoreFiles.Length -gt 0 -or $mismatchedDeleteFiles.Length -gt 0) {

			# the target folder has been modified since the fileset was applied

			$errorMessage = @"
This fileset cannot be restored.
The expected destination files have been removed or modified since this fileset was originally applied.
The files will need to be reverted manually, checking carefully the affected paths.
"@
			if ($missingRestoreFiles.Length -gt 0) {
				$msRestoreList = ($missingRestoreFiles | ForEach-Object { "  * $($_.Dest)"}) -join "`r`n";
				$errorMessage += @"

The following file(s) were marked as overwritten, but do not exist in the destination folder:
$msRestoreList
"@
			}

			if ($mismatchedRestoreFiles.Length -gt 0) {
				$mmRestoreList = ($mismatchedRestoreFiles | ForEach-Object { "  * $($_.Dest)"}) -join "`r`n";
				$errorMessage += @"

The following file(s) were marked as overwritten, but match neither the original files nor the source files of this fileset:
$mmRestoreList
"@
			}

			if ($mismatchedDeleteFiles.Length -gt 0) {
				$mmDeleteList = ($mismatchedDeleteFiles | ForEach-Object { "  * $($_.Dest)"}) -join "`r`n";
				$errorMessage += @"

The following file(s) were new in this fileset, but do not match the source files of this fileset:
$mmDeleteList
"@
			}

			throw $errorMessage
		}

		# checks passed, time to restore

		$overallRestoreActions |
			ForEach-Object {
				Write-Host "Restoring $($_.Backup) to $($_.Dest)"
				Copy-Item -Path $_.Backup -Destination $_.Dest
			}
		
		$overallDeleteActions |
			Where-Object { Test-Path $_.Dest } |
			ForEach-Object {
				Write-Host "Deleting $($_.Dest)"
				Remove-Item -Path $_.Dest
			}

		# restore successful, remove the backup folder and manifest
		$processedFilesets | 
			ForEach-Object {
				Write-Host "Removing restored backup for $($_.BackupFolderPath)"
				Remove-Item $_.BackupManifestPath
				Remove-Item -Recurse -Force $_.BackupFolderPath
			}
	}
}

<#
.Synopsis
Copy the updated files from the source (hotfix) location to their destination.

.Description
Copied files will preserve their tree structure. This function works with the PS
pipeline: an array of file-sets can be piped through the function.
#>
function Copy-FileSet {
	[CmdletBinding()]
	param(
		[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
		[PSObject]$fs
	)
	
	process {
		
		if ($fs.FileDetails.Length -gt 0) {
			
			$fs.FileDetails |
				Resolve-AllFileParentPaths -PropertyName 'Dest' |
				ForEach-Object {
					if ($_.Transform) {
						
						if (Test-Path $_.Dest) {
							# transform baseXml transformXdt destinationXml
							Invoke-XdtTransform $_.Dest $_.Source $_.Dest -Verbose

							# capture the hash of the modified file for use when verifying a restore operation
							Write-BackupDigest $_
						} else {
							Write-Warning -Message "Cannot transform file that doesn't exist: $($_.Dest)"
						}
						
					} else {
						Copy-Item `
								-LiteralPath $_.Source `
								-Destination $_.Dest `
								-PassThru |
							Select-Object -ExpandProperty FullName
						#Copy-ItemWithRetry $_.Source $_.Dest
					}
				} |
				Write-Host;
		}
	}
}

<#
.SYNOPSIS
Writes the hashed digest of the contents of the given file
to a '.digest' file in the backup folder. This allows
modification detection of the results of an XML transformation.

.PARAMETER fileDetails
The file PSObject that needs a digest written.
#>
function Write-BackupDigest([PSObject]$fileDetails) {
	$hashDetails = Get-FileHashCustom -Path $fileDetails.Dest;
	$backupDigestPath = $fileDetails.Backup + '.digest';
	Resolve-AllFileParentPaths -InputObject $fileDetails.Backup | Out-Null;
	$hashDetails.Hash | Set-Content -Path $backupDigestPath;
}

<#
.SYNOPSIS
Reads the backup digest of a file (the content hash of a transformed file).

.PARAMETER fileDetails
The fileDetails PSObject to read the digest for.
#>
function Read-BackupDigest([PSObject]$fileDetails) {
	$backupDigestPath = $fileDetails.Backup + '.digest';
	$digestValue = [string](Get-Content -Path $backupDigestPath | Select-Object -First 1)

	return $digestValue;
}

function Test-BackupDigest([PSObject]$fileDetails) {
	$backupDigestPath = $fileDetails.Backup + '.digest';
	return (Test-Path $backupDigestPath);
}

<#
.SYNOPSIS
For all given paths, create the parent folders required to contain
them. The pipeline may be used to pass objects.

.PARAMETER InputObject
The object to create paths for. This may be a string or another object.
If it is not a string, the PropertyName parameter must be supplied to 
look up a string value from the input object.

.PARAMETER PropertyName
The property name to look up on the input object.
#>
function Resolve-AllFileParentPaths {
	[CmdletBinding()]
	param(
		[Parameter(ValueFromPipeline = $true)]
		$InputObject,
		$PropertyName = $null
	)
	process {
		$filePath = $InputObject;
		if ($PropertyName) {
			$filePath = Select-Object -InputObject $InputObject -ExpandProperty $PropertyName
		}

		$folderName = (Split-Path -Path $filePath -Parent);

		if (-not (Test-Path $folderName)) {
			Write-Host "Creating directory $folderName"
			New-Item -Force -ItemType Directory -Path $folderName | Out-Null
		}
		
		Write-Output $InputObject; # PassThru
	}
}

function Invoke-XdtTransform {
	[CmdletBinding()]
	param(
		[Parameter(Mandatory = $true)]
		[string]$SourceXML,
		[Parameter(Mandatory = $true)]
		[string]$TransformXDT,
		[Parameter(Mandatory = $true)]
		[string]$DestinationXML
	)

	$slowCheetah = (Join-Path $commonScriptDir 'bin\SlowCheetah.Xdt.exe');

	$args = @($SourceXML, $TransformXDT, $DestinationXML, '-info');

	Write-Host "Transforming: $DestinationXML"

	& $slowCheetah $args
	if ($LastExitCode -ne 0) {
		Write-Error -Message "Configuration file transformation failed ($LastExitCode), see messages for details."
	}
}

# (internal)
# Get the set of relative paths that exist under the base path,
# optionally filtering the result path
function Get-FileSetSourceTree([string]$basePath, [string]$filter = '*') {
	
	$basePath = (Resolve-Path $basePath).Path;
	
	# the recursive set of files (no folders) stripped of the base folder
	return @(
		Get-ChildItem -Recurse -Path $basePath -Filter $filter | 
		Where-Object { -not $_.PSIsContainer } | # this will cause empty folders to be skipped
		Select-Object -ExpandProperty FullName | 
		Remove-BasePath -BasePath $basePath
	);
}

function Remove-BasePath {
	[CmdletBinding()]
	param (
		[Parameter(Mandatory = $true)]
		[string]
		$BasePath,
		[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
		[string]
		$FilePath
	)

	begin {
		# ensure the base path ends with a backslash
		if ($BasePath -notlike '*\') {
			$BasePath += '\'
		}
		$BasePath = (Resolve-Path $BasePath).Path;
		$basePathExp = '^' + [System.Text.RegularExpressions.RegEx]::Escape($BasePath);
	}

	process {
		$subPath = $FilePath -replace $basePathExp,''
		Write-Output $subPath
	}
}

function Get-BackupPath($backupBasePath, $destPath) {
	return (Join-Path $backupBasePath (Get-ContentHash $destPath -Algorithm 'MD5'))
}

function Copy-ItemWithRetry {
	param(
		[Parameter(Mandatory = $true)]
		[string]$path,
		[Parameter(Mandatory = $true)]
		[string]$dest
	)
	
	$retryCount = 10
	$retryWaitSeconds = 30
	
	$iterationCount = 1
	$succeeded = $false

	$resultPath = ''

	while ($iterationCount -le $retryCount -and !$succeeded) {
		try {
			$resultPath = Copy-Item `
					-LiteralPath $path `
					-Destination $dest `
					-PassThru |
				Select-Object -ExpandProperty FullName

			$succeeded = $true
			Write-Host "Copy was successful"
		}
		catch {
			Write-Warning "An error occurred copying '$path' (attempt $iterationCount) - $_"
			Start-Sleep -seconds $retryWaitSeconds
		}

		$iterationCount = $iterationCount + 1
	}

	if (!$succeeded) {
		Write-Error "This update has failed. File at path $dest could not be updated sucessfully."
	}

	$resultPath
}
