// The Windows installer target. The generated NSIS script is intentionally
// repeatable so it can replace an existing installation.

///|
/// NSIS derives the silent-install switch (`/S`) itself.
let windows_installer_tool : String = "makensis"

///|
fn windows_installer_path(plan : PackageSpec) -> String {
  resolve_path(plan.output, "\{executable_name(plan.product_name)}-setup.exe")
}

///|
fn windows_installer_staging_path(plan : PackageSpec) -> String {
  resolve_path(
    plan.output,
    ".\{executable_name(plan.product_name)}.staging-setup.exe",
  )
}

///|
fn windows_installer_script_path(plan : PackageSpec) -> String {
  resolve_path(
    plan.output,
    ".\{executable_name(plan.product_name)}.installer.nsi",
  )
}

///|
/// The registry key holding the install location. The updater does not read
/// it — it re-runs the installer, which resolves the location itself — but
/// Add/Remove Programs and a manual reinstall both do.
fn windows_installer_registry_key(plan : PackageSpec) -> String {
  "Software\\\{plan.identifier}"
}

///|
fn windows_uninstall_registry_key(plan : PackageSpec) -> String {
  "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\\{plan.identifier}"
}

///|
/// `VIProductVersion` rejects anything that is not exactly four numeric
/// fields, so a semantic version has to be padded and any pre-release suffix
/// dropped. The human-readable version still ships in `FileVersion`.
fn windows_installer_version_quad(version : String) -> String {
  let digits = []
  let current = StringBuilder::new()
  for char in version {
    if char.is_ascii_digit() {
      current.write_char(char)
    } else {
      if current.to_string() != "" {
        digits.push(current.to_string())
        current.reset()
      }
      if char != '.' {
        break
      }
    }
  }
  if current.to_string() != "" {
    digits.push(current.to_string())
  }
  while digits.length() < 4 {
    digits.push("0")
  }
  let quad = digits[:4]
  quad.to_owned().join(".")
}

///|
/// Escapes a value for an NSIS double-quoted string.
///
/// `$` opens a variable reference such as `$INSTDIR` and is doubled to stay
/// literal; a quote is written `$\"`. The `$` pass has to run first, or it
/// would go back over the `$` that the quote escape just introduced.
/// Backslashes are ordinary characters here and must be left alone, otherwise
/// every Windows path in the script breaks.
fn windows_installer_quote(value : String) -> String {
  value.replace_all(old="$", new="$$").replace_all(old="\"", new="$\\\"")
}

///|
/// Renders the installer script.
///
/// Kept pure so the generated text is testable on any host; running
/// `makensis` needs Windows tooling that only the packaging step has.
fn windows_installer_script(plan : PackageSpec, source : String) -> String {
  let product = windows_installer_quote(plan.product_name)
  let executable = "\{executable_name(plan.product_name)}.exe"
  let builder = StringBuilder::new()
  builder <+
    #|Unicode true
    #|ManifestDPIAware true
    #|SetCompressor /SOLID lzma
    #|RequestExecutionLevel admin
    #|
    #|!include "MUI2.nsh"
    #|
  builder <+
    $|Name "\{product}"
    $|OutFile "\{windows_installer_quote(windows_installer_staging_path(plan))}"
    $|InstallDir "$PROGRAMFILES64\\\{product}"
    $|InstallDirRegKey HKLM "\{windows_installer_quote(windows_installer_registry_key(plan))}" "InstallDir"
    $|
    $|VIProductVersion "\{windows_installer_version_quad(plan.version)}"
    $|VIAddVersionKey "ProductName" "\{product}"
    $|VIAddVersionKey "FileVersion" "\{windows_installer_quote(plan.version)}"
    $|VIAddVersionKey "ProductVersion" "\{windows_installer_quote(plan.version)}"
    $|VIAddVersionKey "FileDescription" "\{product} Setup"
    $|
  builder <+
    #|!define MUI_ABORTWARNING
    #|!insertmacro MUI_PAGE_DIRECTORY
    #|!insertmacro MUI_PAGE_INSTFILES
    #|!insertmacro MUI_UNPAGE_CONFIRM
    #|!insertmacro MUI_UNPAGE_INSTFILES
    #|!insertmacro MUI_LANGUAGE "English"
    #|
    #|Section "Install"
    #|
  builder <+
    $|  ; An update re-runs this installer while the application it is
    $|  ; replacing is still running, so close it before overwriting.
    $|  nsExec::Exec 'taskkill /F /IM "\{executable}"'
    $|  Pop $0
    $|  Sleep 500
    $|
    $|  SetOutPath "$INSTDIR"
    $|  File /r "\{windows_installer_quote(source)}\\*.*"
    $|  WriteUninstaller "$INSTDIR\\Uninstall.exe"
    $|
    $|  WriteRegStr HKLM "\{windows_installer_quote(windows_installer_registry_key(plan))}" "InstallDir" "$INSTDIR"
    $|  WriteRegStr HKLM "\{windows_installer_quote(windows_installer_registry_key(plan))}" "Version" "\{windows_installer_quote(plan.version)}"
    $|  WriteRegStr HKLM "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "DisplayName" "\{product}"
    $|  WriteRegStr HKLM "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "DisplayVersion" "\{windows_installer_quote(plan.version)}"
    $|  WriteRegStr HKLM "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "UninstallString" "$\\"$INSTDIR\\Uninstall.exe$\\""
    $|  WriteRegStr HKLM "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "InstallLocation" "$INSTDIR"
    $|  WriteRegStr HKLM "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "DisplayIcon" "$INSTDIR\\\{executable}"
    $|  WriteRegDWORD HKLM "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "NoModify" 1
    $|  WriteRegDWORD HKLM "\{windows_installer_quote(windows_uninstall_registry_key(plan))}" "NoRepair" 1
    $|
    $|  CreateShortCut "$SMPROGRAMS\\\{product}.lnk" "$INSTDIR\\\{executable}"
    $|SectionEnd
    $|
  builder <+
    $|Section "Uninstall"
    $|  nsExec::Exec 'taskkill /F /IM "\{executable}"'
    $|  Pop $0
    $|  Delete "$SMPROGRAMS\\\{product}.lnk"
    $|  RMDir /r "$INSTDIR"
    $|  DeleteRegKey HKLM "\{windows_installer_quote(windows_installer_registry_key(plan))}"
    $|  DeleteRegKey HKLM "\{windows_installer_quote(windows_uninstall_registry_key(plan))}"
    $|SectionEnd
    $|
  builder.to_string()
}

///|
/// Builds the installer from an already-staged portable directory.
async fn create_windows_installer(
  plan : PackageSpec,
  source : String,
  announce : Bool,
) -> Unit {
  let installer = windows_installer_path(plan)
  let staging = windows_installer_staging_path(plan)
  let script = windows_installer_script_path(plan)
  remove_tree(staging)
  @async_fs.write_file(
    script,
    windows_installer_script(plan, source),
    create_mode=CreateOrTruncate,
  ) catch {
    error =>
      raise WindowsPackagingError::ToolFailure(
        stage="write Windows installer script",
        detail="\{script}: \{@debug.render(Repr(error))}",
      )
  }
  let (code, _) = @process.collect_output_merged(windows_installer_tool, [
    "/V2", script,
  ]) catch {
    _ =>
      raise WindowsPackagingError::ToolFailure(
        stage="create Windows installer",
        detail="\{windows_installer_tool} is not available; install NSIS to build the installer target",
      )
  }
  guard code == 0 else {
    raise WindowsPackagingError::ToolFailure(
      stage="create Windows installer",
      detail="exit code \{code}",
    )
  }
  guard path_exists(staging) else {
    raise WindowsPackagingError::Verification(
      detail="installer was not produced: \{staging}",
    )
  }
  promote(staging, installer)
  if announce {
    println("Created installer: \{installer}")
  }
  ()
}