#requires -Version 5.1
[CmdletBinding()]
param(
  [Parameter(Mandatory = $true)]
  [ValidateNotNullOrEmpty()]
  [string]$CertificateThumbprint,

  [Parameter(Mandatory = $true)]
  [ValidateNotNullOrEmpty()]
  [string]$Customer,

  [Parameter(Mandatory = $true)]
  [ValidatePattern('^[0-9A-Fa-f]{32}$')]
  [string]$InstallationId,

  [Parameter(Mandatory = $true)]
  [ValidateNotNullOrEmpty()]
  [string]$OutputFile,

  [ValidateNotNullOrEmpty()]
  [string]$LicenseId = ([Guid]::NewGuid().ToString('D')),

  [DateTime]$NotBefore = ((Get-Date).ToUniversalTime().AddMinutes(-5)),

  [DateTime]$Expires = ((Get-Date).ToUniversalTime().AddYears(1)),

  [switch]$Perpetual,

  [switch]$Force
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if ($env:OS -ne 'Windows_NT') {
  throw 'SmartCut64 licence issuance requires Windows.'
}
foreach ($field in @(@{ Name = 'Customer'; Value = $Customer },
                       @{ Name = 'LicenseId'; Value = $LicenseId })) {
  if ($field.Value -match '[\x00-\x1f\x7f]') {
    throw "$($field.Name) must not contain control characters."
  }
}
if ($Customer.Length -gt 512) { throw 'Customer must be 512 characters or fewer.' }
if ($LicenseId.Length -gt 128) { throw 'LicenseId must be 128 characters or fewer.' }

$notBeforeUtc = $NotBefore.ToUniversalTime()
$expiresUtc = $Expires.ToUniversalTime()
if (-not $Perpetual -and $expiresUtc -le $notBeforeUtc) {
  throw 'Expires must be later than NotBefore, or use -Perpetual.'
}

$thumbprint = $CertificateThumbprint -replace '\s', ''
if ($thumbprint -notmatch '^[0-9A-Fa-f]{40}$') { throw 'CertificateThumbprint must be a 40-character SHA-1 thumbprint.' }
$certificatePath = "Cert:\CurrentUser\My\$thumbprint"
$certificate = Get-Item -LiteralPath $certificatePath -ErrorAction Stop
if (-not $certificate.HasPrivateKey) {
  throw "The certificate has no private key: $certificatePath"
}
$rsa = [Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate)
if ($null -eq $rsa) { throw 'The certificate private key is not RSA.' }

$issuedUnix = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$notBeforeUnix = ([DateTimeOffset]$notBeforeUtc).ToUnixTimeSeconds()
$expiresUnix = if ($Perpetual) { [Int64]0 } else { ([DateTimeOffset]$expiresUtc).ToUnixTimeSeconds() }
$normalizedInstallation = $InstallationId.ToLowerInvariant()
$payload = @(
  'product=SmartCut64'
  'edition=paid'
  "license_id=$LicenseId"
  "customer=$Customer"
  "installation_id=$normalizedInstallation"
  "issued_unix=$issuedUnix"
  "not_before_unix=$notBeforeUnix"
  "expires_unix=$expiresUnix"
  'clean_export=1'
) -join "`n"

$base64Url = {
  param([byte[]]$Bytes)
  [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
$utf8 = New-Object Text.UTF8Encoding($false)
$payloadSegment = & $base64Url $utf8.GetBytes($payload)
$signedText = "SC64-1.$payloadSegment"
try {
  $signature = $rsa.SignData(
    [Text.Encoding]::ASCII.GetBytes($signedText),
    [Security.Cryptography.HashAlgorithmName]::SHA256,
    [Security.Cryptography.RSASignaturePadding]::Pkcs1)
}
finally {
  $rsa.Dispose()
}
$token = "$signedText.$(& $base64Url $signature)"

$outputPath = [IO.Path]::GetFullPath($OutputFile)
if ((Test-Path -LiteralPath $outputPath) -and -not $Force) {
  throw "The licence file already exists: $outputPath. Use -Force to replace it."
}
$outputDirectory = [IO.Path]::GetDirectoryName($outputPath)
if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) {
  New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
$temporaryPath = [IO.Path]::Combine(
  $outputDirectory,
  '.' + [IO.Path]::GetFileName($outputPath) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp')
try {
  [IO.File]::WriteAllText($temporaryPath, $token + [Environment]::NewLine, $utf8)
  if (Test-Path -LiteralPath $outputPath) {
    if (-not $Force) {
      throw "The licence file appeared while it was being issued: $outputPath"
    }
    [IO.File]::Replace($temporaryPath, $outputPath, $null)
  }
  else {
    [IO.File]::Move($temporaryPath, $outputPath)
  }
}
finally {
  if (Test-Path -LiteralPath $temporaryPath) {
    Remove-Item -LiteralPath $temporaryPath -Force
  }
}

[pscustomobject]@{
  LicenseFile = $outputPath
  LicenseId = $LicenseId
  Customer = $Customer
  InstallationId = $normalizedInstallation
  NotBeforeUtc = $notBeforeUtc.ToString('o')
  ExpiresUtc = if ($Perpetual) { 'Perpetual' } else { $expiresUtc.ToString('o') }
  CertificateThumbprint = $certificate.Thumbprint
}
