ClientAssets = {}

local DEFAULT_CONFIG = {
  enabled = true,
  repository = 'dudantas/tibia-client',
  rawBaseUrl = 'https://raw.githubusercontent.com/%s/%s/',
  installSounds = true,
  timeout = 30,
  retries = 2,
  preferPackedManifestUrls = true,
  strictManifestSha256 = true,
  allowRawFallbackHashMismatch = false,
  allowMissingPackedRawFallback = true,
  preferArchive = true,
  fallbackToArchiveOnManifestFailure = false,
  installArchiveExtras = true,
  archiveExtraPrefixes = { 'bin' },
  archiveExtrasDestination = '',
  installPackagedFiles = true,
  packagedFilesDestination = '',
  packagedFilesRequired = false,
  installInWorkDir = true
}

local activeDownload
local releasesCache = {}
local ARCHIVE_EXTENSIONS = { '.zip', '.rar' }
local DOWNLOAD_WINDOW_WIDTH = 360
local DOWNLOAD_WINDOW_HEIGHT = 140
local DOWNLOAD_TEXT_MARGIN = 34
local DOWNLOAD_BAR_HEIGHT = 22
local BUSY_INDICATOR_IMAGE = '/client_assets/images/hourglass'
local BUSY_INDICATOR_SIZE = 20
local BUSY_INDICATOR_ROTATION_DELAY = 650
local BUSY_INDICATOR_ROTATION_STEP = 180
local HTTP_NOT_FOUND_SHA256 = 'd5558cd419c8d46bdc958064cb97f963d1ea793866414c025906ec15033512ed'

local function logInfo(message)
  g_logger.info('[client_assets] ' .. tostring(message))
end

local function logWarning(message)
  g_logger.warning('[client_assets] ' .. tostring(message))
end

local function logError(message)
  g_logger.error('[client_assets] ' .. tostring(message))
end

local function startsWith(value, prefix)
  return type(value) == 'string' and value:sub(1, #prefix) == prefix
end

local function endsWith(value, suffix)
  return type(value) == 'string' and suffix ~= '' and value:sub(-#suffix) == suffix
end

local function isArchivePath(path)
  path = tostring(path or ''):lower()
  for _, extension in ipairs(ARCHIVE_EXTENSIONS) do
    if endsWith(path, extension) then
      return true
    end
  end
  return false
end

local function isLzmaPath(path)
  return endsWith(tostring(path or ''):lower(), '.lzma')
end

local function isMacArchivePath(path)
  path = tostring(path or ''):lower()
  return endsWith(path, '.app.zip') or
         path:find('macos', 1, true) or
         path:find('%f[%a]mac%f[^%a]')
end

local function isNotFoundError(message)
  message = tostring(message or ''):lower()
  return message:find('404', 1, true) or message:find('not found', 1, true)
end

local function isMissingPackedHashError(message)
  return tostring(message or ''):find(HTTP_NOT_FOUND_SHA256, 1, true) ~= nil
end

local function isPackedToRawFallback(downloadInfo, fallbackDownload)
  if not downloadInfo or not fallbackDownload then
    return false
  end

  local sourcePath = tostring(downloadInfo.sourcePath or '')
  local fallbackPath = tostring(fallbackDownload.sourcePath or '')
  if fallbackPath == '' or isLzmaPath(fallbackPath) or isArchivePath(fallbackPath) then
    return false
  end

  return isLzmaPath(sourcePath) or isArchivePath(sourcePath)
end

local function parentPath(path)
  path = tostring(path or ''):gsub('\\', '/')
  return path:match('^(.*)/[^/]+$') or ''
end

local function stripArchiveExtension(path)
  for _, extension in ipairs(ARCHIVE_EXTENSIONS) do
    if endsWith(path:lower(), extension) then
      return path:sub(1, #path - #extension)
    end
  end
  return path
end

local function toList(value)
  if type(value) == 'table' then
    return value
  end
  if type(value) == 'string' then
    return { value }
  end
  return {}
end

local function matchesAnyPrefix(path, prefixes)
  path = tostring(path or '')
  local prefixList = toList(prefixes)
  if #prefixList == 0 then
    return true
  end
  for _, prefix in ipairs(prefixList) do
    if startsWith(path, prefix) then
      return true
    end
  end
  return false
end

local function joinUrl(baseUrl, path)
  if not baseUrl or baseUrl == '' then
    return path
  end
  if startsWith(path, 'http://') or startsWith(path, 'https://') then
    return path
  end
  if not endsWith(baseUrl, '/') then
    baseUrl = baseUrl .. '/'
  end
  while startsWith(path, '/') do
    path = path:sub(2)
  end
  return baseUrl .. path
end

local function cloneConfig()
  local config = {}
  for key, value in pairs(DEFAULT_CONFIG) do
    config[key] = value
  end

  local custom = Services and Services.clientAssets
  if custom == false then
    config.enabled = false
    return config
  end

  if type(custom) == 'string' then
    config.manifestUrl = custom
  elseif type(custom) == 'table' then
    for key, value in pairs(custom) do
      config[key] = value
    end
  end

  if not config.releasesUrl and config.repository then
    config.releasesUrl = string.format('https://api.github.com/repos/%s/releases?per_page=100', config.repository)
  end

  return config
end

local function withHttpTimeout(config, fn)
  local oldTimeout = HTTP.timeout
  HTTP.timeout = config.timeout or oldTimeout
  local ok, result = pcall(fn)
  HTTP.timeout = oldTimeout
  if not ok then
    error(result)
  end
  return result
end

local function httpGet(config, url, callback)
  return withHttpTimeout(config, function()
    return HTTP.get(url, callback)
  end)
end

local function httpGetJSON(config, url, callback)
  return withHttpTimeout(config, function()
    return HTTP.getJSON(url, callback)
  end)
end

local function httpDownload(config, url, path, callback, progressCallback)
  return withHttpTimeout(config, function()
    return HTTP.download(url, path, callback, progressCallback)
  end)
end

local function versionLabel(version)
  version = tonumber(version) or 0
  return string.format('%d.%02d', math.floor(version / 100), version % 100)
end

local function shortenText(value, maxLength)
  value = tostring(value or '')
  maxLength = maxLength or 56
  if #value <= maxLength then
    return value
  end
  if maxLength <= 8 then
    return value:sub(1, maxLength)
  end
  local head = math.floor((maxLength - 3) / 2)
  local tail = maxLength - 3 - head
  return value:sub(1, head) .. '...' .. value:sub(#value - tail + 1)
end

local function userFacingError(message)
  if not message then
    return nil
  end

  message = tostring(message)
  if message:find('SHA%-256') or #message > 180 then
    return 'Unable to download client assets.\nSee the console for full details.\n' .. shortenText(message, 120)
  end

  return message
end

local function createDownloadWindow()
  local initialText = tr('Preparing download...') .. '\n0%'
  local window = displayCancelBox(tr('Downloading Assets'), initialText)
  local width = DOWNLOAD_WINDOW_WIDTH
  if rootWidget then
    width = math.min(width, math.max(280, rootWidget:getWidth() - 40))
  end

  window:setWidth(width)
  window:setHeight(DOWNLOAD_WINDOW_HEIGHT)

  window.content:setTextWrap(true)
  window.content:setWidth(width - DOWNLOAD_TEXT_MARGIN)
  window.content:setText(initialText)
  window.content:resizeToText()

  local progressBar = g_ui.createWidget('UIWidget', window)
  progressBar:setId('assetDownloadProgressBar')
  progressBar:setHeight(DOWNLOAD_BAR_HEIGHT)
  progressBar:setBackgroundColor('#1a1a1a')
  progressBar:setBorderWidth(1)
  progressBar:setBorderColor('#000000')
  progressBar:addAnchor(AnchorLeft, 'parent', AnchorLeft)
  progressBar:addAnchor(AnchorRight, 'parent', AnchorRight)
  progressBar:addAnchor(AnchorTop, 'content', AnchorBottom)
  progressBar:setMarginLeft(15)
  progressBar:setMarginRight(15)
  progressBar:setMarginTop(8)
  window.progressBar = progressBar

  local progressFill = g_ui.createWidget('UIWidget', progressBar)
  progressFill:setId('assetDownloadProgressFill')
  progressFill:setBackgroundColor('#4444ff')
  progressFill:setHeight(DOWNLOAD_BAR_HEIGHT - 2)
  progressFill:setWidth(0)
  progressFill:addAnchor(AnchorLeft, 'parent', AnchorLeft)
  progressFill:addAnchor(AnchorTop, 'parent', AnchorTop)
  progressFill:addAnchor(AnchorBottom, 'parent', AnchorBottom)
  progressFill:setMarginLeft(1)
  progressFill:setMarginTop(1)
  progressFill:setMarginBottom(1)
  window.progressFill = progressFill

  local busyIndicator = g_ui.createWidget('UIWidget', window)
  busyIndicator:setId('assetDownloadBusyIndicator')
  busyIndicator:setSize(string.format('%d %d', BUSY_INDICATOR_SIZE, BUSY_INDICATOR_SIZE))
  busyIndicator:setImageSource(BUSY_INDICATOR_IMAGE)
  busyIndicator:setImageSmooth(true)
  busyIndicator:setPhantom(true)
  busyIndicator:setVisible(false)
  busyIndicator:addAnchor(AnchorHorizontalCenter, 'assetDownloadProgressBar', AnchorHorizontalCenter)
  busyIndicator:addAnchor(AnchorVerticalCenter, 'assetDownloadProgressBar', AnchorVerticalCenter)
  busyIndicator:raise()
  window.busyIndicator = busyIndicator

  local separator = window:getChildById('messageBoxSeparator')
  if separator then
    separator:setVisible(false)
  end

  window.holder = window:getChildById('holder')
  if window.holder then
    window.holder:breakAnchors()
    window.holder:addAnchor(AnchorRight, 'assetDownloadProgressBar', AnchorRight)
    window.holder:addAnchor(AnchorLeft, 'assetDownloadProgressBar', AnchorLeft)
    window.holder:addAnchor(AnchorTop, 'assetDownloadProgressBar', AnchorBottom)
    window.holder:addAnchor(AnchorBottom, 'parent', AnchorBottom)
    window.holder:setMarginTop(14)
  end

  return window
end

local function clampPercent(percent)
  percent = math.floor(tonumber(percent) or 0)
  return math.max(0, math.min(100, percent))
end

local function updateDownloadBar(window, percent)
  if not window or not window.progressFill or not window.progressBar then
    return
  end

  percent = clampPercent(percent)
  local width = math.max(0, (window.progressBar:getWidth() or 0) - 2)
  window.progressFill:setWidth(math.floor(width * percent / 100))
end

local function completeMarkerPath(version)
  return string.format('data/things/%d/.client-assets-complete', version)
end

local function joinPhysicalPath(base, path)
  base = tostring(base or ''):gsub('\\', '/'):gsub('/+$', '')
  path = tostring(path or ''):gsub('\\', '/'):gsub('^/+', '')
  if base == '' then
    return path
  end
  return base .. '/' .. path
end

local function physicalInstallPath(path)
  return joinPhysicalPath(g_resources.getWorkDir(), path)
end

local function shouldInstallInWorkDir(config)
  return config and config.installInWorkDir ~= false
end

local function writeInstallFile(config, path, contents)
  if shouldInstallInWorkDir(config) and g_resources.writeFileContentsToWorkDir then
    return g_resources.writeFileContentsToWorkDir(path, contents)
  end

  local directory = parentPath(path)
  if directory ~= '' then
    g_resources.makeDir('/' .. directory)
  end
  return g_resources.writeFileContents('/' .. path, contents)
end

local function hasCatalogEntryFile(basePath, entry)
  if type(entry) ~= 'table' or type(entry.file) ~= 'string' then
    return false
  end
  return g_resources.fileExists(basePath .. entry.file)
end

local function installFileExists(path)
  if g_resources.fileExistsInWorkDir and g_resources.fileExistsInWorkDir(path) then
    return true
  end
  return g_resources.fileExists('/' .. path)
end

local function readInstallFile(path)
  if g_resources.readFileContentsFromWorkDir and
     (not g_resources.fileExistsInWorkDir or g_resources.fileExistsInWorkDir(path)) then
    local ok, contents = pcall(function() return g_resources.readFileContentsFromWorkDir(path) end)
    if ok and type(contents) == 'string' then
      return contents
    end
  end
  return g_resources.readFileContents('/' .. path)
end

local function hasInstallCatalogEntryFile(basePath, entry)
  if type(entry) ~= 'table' or type(entry.file) ~= 'string' then
    return false
  end
  return installFileExists(basePath .. entry.file)
end

local function hasModernClientFilesAtPath(basePath, installPath)
  local fileExists = installPath and installFileExists or g_resources.fileExists
  local readFile = installPath and readInstallFile or g_resources.readFileContents

  if not fileExists(basePath .. 'catalog-content.json') or
     not fileExists(basePath .. 'assets.json.sha256') then
    return false
  end

  local ok, data = pcall(readFile, basePath .. 'catalog-content.json')
  if not ok or type(data) ~= 'string' then
    return false
  end

  local decodeOk, catalog = pcall(json.decode, data)
  if not decodeOk or type(catalog) ~= 'table' then
    return false
  end

  local hasAppearances = false
  local hasStaticData = false
  for _, entry in ipairs(catalog) do
    local entryType = entry.type
    if entryType == 'appearances' or entryType == 'staticdata' or entryType == 'proficiencies' then
      if installPath and not hasInstallCatalogEntryFile(basePath, entry) then
        return false
      elseif not installPath and not hasCatalogEntryFile(basePath, entry) then
        return false
      end
      if entryType == 'appearances' then
        hasAppearances = true
      elseif entryType == 'staticdata' then
        hasStaticData = true
      end
    end
  end

  return hasAppearances and hasStaticData
end

local function hasModernClientFiles(version)
  return hasModernClientFilesAtPath(string.format('data/things/%d/', version), true)
end

local hasInstalledModernClientFiles = hasModernClientFiles

local function markClientVersionInstalled(config, version)
  local markerPath = completeMarkerPath(version)
  local contents = string.format('client=%d\n', version)
  if shouldInstallInWorkDir(config) and g_resources.writeFileContentsToWorkDir then
    return g_resources.writeFileContentsToWorkDir(markerPath, contents)
  end

  g_resources.makeDir(string.format('/data/things/%d', version))
  return g_resources.writeFileContents('/' .. markerPath, contents)
end

local function normalizeVersionKey(version)
  return tostring(tonumber(version) or version)
end

local function readVersionEntry(manifest, version)
  local key = normalizeVersionKey(version)
  if type(manifest) ~= 'table' then
    return nil
  end
  if type(manifest.versions) == 'table' then
    return manifest.versions[key] or manifest.versions[tonumber(version)]
  end
  return manifest[key] or manifest[tonumber(version)]
end

local function normalizeDescriptor(config, version, descriptor)
  if type(descriptor) ~= 'table' then
    return nil
  end

  descriptor.version = tonumber(version)
  descriptor.installSounds = descriptor.installSounds
  if descriptor.installSounds == nil then
    descriptor.installSounds = config.installSounds
  end

  descriptor.baseUrl = descriptor.baseUrl or descriptor.rawBaseUrl
  if not descriptor.baseUrl and descriptor.tag and config.repository then
    descriptor.baseUrl = string.format(config.rawBaseUrl, config.repository, descriptor.tag)
  end

  descriptor.manifestUrl = descriptor.manifestUrl or descriptor.manifest or descriptor.assetsManifest
  descriptor.manifestSha256 = descriptor.manifestSha256 or descriptor.assetsManifestSha256
  descriptor.manifestSha256Url = descriptor.manifestSha256Url or descriptor.assetsManifestSha256Url

  if type(descriptor.archive) == 'table' then
    descriptor.archiveUrl = descriptor.archive.url or descriptor.archiveUrl
    descriptor.archiveSha256 = descriptor.archive.sha256 or descriptor.archiveSha256
    descriptor.archiveThingsPrefix = descriptor.archive.thingsPrefix or descriptor.archiveThingsPrefix
    descriptor.archiveSoundsPrefix = descriptor.archive.soundsPrefix or descriptor.archiveSoundsPrefix
  end

  descriptor.archiveUrl = descriptor.archiveUrl or descriptor.zipUrl or descriptor.packageUrl
  descriptor.archiveThingsPrefix = descriptor.archiveThingsPrefix or 'assets'
  descriptor.archiveSoundsPrefix = descriptor.archiveSoundsPrefix or 'sounds'
  descriptor.installArchiveExtras = descriptor.installArchiveExtras
  if descriptor.installArchiveExtras == nil then
    descriptor.installArchiveExtras = config.installArchiveExtras
  end
  descriptor.archiveExtraPrefixes = descriptor.archiveExtraPrefixes or config.archiveExtraPrefixes
  descriptor.archiveExtrasDestination = descriptor.archiveExtrasDestination or config.archiveExtrasDestination
  descriptor.treeUrl = descriptor.treeUrl or descriptor.packagedFilesUrl
  if not descriptor.treeUrl and descriptor.tag and config.repository then
    descriptor.treeUrl = string.format('https://api.github.com/repos/%s/git/trees/%s?recursive=1', config.repository, descriptor.tag)
  end
  descriptor.installPackagedFiles = descriptor.installPackagedFiles
  if descriptor.installPackagedFiles == nil then
    descriptor.installPackagedFiles = config.installPackagedFiles
  end
  descriptor.packagedFilePrefixes = descriptor.packagedFilePrefixes or config.packagedFilePrefixes
  descriptor.packagedFilesDestination = descriptor.packagedFilesDestination or config.packagedFilesDestination
  descriptor.packagedFilesRequired = descriptor.packagedFilesRequired
  if descriptor.packagedFilesRequired == nil then
    descriptor.packagedFilesRequired = config.packagedFilesRequired
  end

  return descriptor
end

local function findReleaseArchive(release, version)
  if type(release.assets) ~= 'table' then
    return nil
  end

  local tag = tostring(release.tag_name or ''):lower()
  local label = version and versionLabel(version):lower() or ''
  local bestUrl
  local bestScore = 0

  for _, asset in ipairs(release.assets) do
    local name = tostring(asset.name or ''):lower()
    local url = asset.browser_download_url
    if url and isArchivePath(name) and not isMacArchivePath(name) then
      local score = 0
      if tag ~= '' and name:find(tag, 1, true) then
        score = score + 4
      end
      if label ~= '' and name:find(label, 1, true) then
        score = score + 2
      end

      -- Releases may contain unrelated legacy client archives. Only accept
      -- an archive that identifies the requested tag or client version.
      if score > 0 then
        if name:find('client', 1, true) then
          score = score + 1
        end
        if name:find('original', 1, true) or name:find('linux', 1, true) then
          score = score - 1
        end
        if score > bestScore then
          bestUrl = url
          bestScore = score
        end
      end
    end
  end

  return bestUrl
end

local function codeloadZipUrl(repository, tag)
  if not repository or not tag then
    return nil
  end
  return string.format('https://codeload.github.com/%s/zip/refs/tags/%s', repository, tag)
end

local function descriptorFromRelease(config, version, release)
  local tag = release.tag_name
  if not tag or tag == '' then
    return nil
  end

  local baseUrl = string.format(config.rawBaseUrl, config.repository, tag)
  return normalizeDescriptor(config, version, {
    tag = tag,
    baseUrl = baseUrl,
    manifestUrl = baseUrl .. 'assets.json',
    manifestSha256Url = baseUrl .. 'assets.json.sha256',
    treeUrl = string.format('https://api.github.com/repos/%s/git/trees/%s?recursive=1', config.repository, tag),
    archiveUrl = findReleaseArchive(release, version) or codeloadZipUrl(config.repository, tag)
  })
end

local function findReleaseForVersion(releases, version)
  local label = versionLabel(version):lower()
  for _, release in ipairs(releases) do
    local tag = tostring(release.tag_name or ''):lower()
    local name = tostring(release.name or ''):lower()
    if tag:find(label, 1, true) or name:find(label, 1, true) then
      return release
    end
  end
  return nil
end

local function overallProgress(index, total, fileProgress)
  total = math.max(tonumber(total) or 1, 1)
  index = math.max(tonumber(index) or 1, 1)
  fileProgress = math.max(0, math.min(100, tonumber(fileProgress) or 0))
  return clampPercent(((index - 1) + (fileProgress / 100)) * 100 / total)
end

local function stopDownloadBusy()
  if activeDownload and activeDownload.busyEvent then
    removeEvent(activeDownload.busyEvent)
    activeDownload.busyEvent = nil
  end
  if activeDownload and activeDownload.busySpinEvent then
    removeEvent(activeDownload.busySpinEvent)
    activeDownload.busySpinEvent = nil
  end
  if activeDownload and activeDownload.window and activeDownload.window.busyIndicator then
    activeDownload.window.busyIndicator:setRotation(0)
    activeDownload.window.busyIndicator:setVisible(false)
  end
end

local function renderDownloadStatus(status, percent, detail)
  if not activeDownload or not activeDownload.window or not activeDownload.window.content then
    return
  end

  local width = DOWNLOAD_WINDOW_WIDTH
  if rootWidget then
    width = math.min(width, math.max(260, rootWidget:getWidth() - 40))
  end

  activeDownload.window:setWidth(width)
  activeDownload.window.content:setTextWrap(true)
  activeDownload.window.content:setWidth(width - DOWNLOAD_TEXT_MARGIN)
  if percent then
    percent = clampPercent(percent)
    activeDownload.window.content:setText(string.format('%s\n%d%%', status, percent))
  elseif detail and detail ~= '' then
    activeDownload.window.content:setText(status .. '\n' .. detail)
  else
    activeDownload.window.content:setText(status)
  end
  activeDownload.window.content:resizeToText()
  activeDownload.window:setHeight(DOWNLOAD_WINDOW_HEIGHT)
  if percent and activeDownload.window.busyIndicator then
    activeDownload.window.busyIndicator:setRotation(0)
    activeDownload.window.busyIndicator:setVisible(false)
  end
  if activeDownload.window.progressBar then
    if percent then
      updateDownloadBar(activeDownload.window, percent)
    else
      updateDownloadBar(activeDownload.window, 0)
    end
  end
end

local function setDownloadProgress(status, percent, detail)
  stopDownloadBusy()
  renderDownloadStatus(status, percent, detail)
end

local function scheduleDownloadStep(callback)
  scheduleEvent(function()
    if not activeDownload or activeDownload.canceled then
      return
    end
    callback()
  end, 50)
end

local function startBusyIndicatorSpin()
  if not activeDownload or activeDownload.busySpinEvent then
    return
  end

  local function spin()
    if not activeDownload or not activeDownload.window or not activeDownload.window.busyIndicator then
      return
    end

    activeDownload.busyRotation = ((activeDownload.busyRotation or 0) + BUSY_INDICATOR_ROTATION_STEP) % 360
    activeDownload.window.busyIndicator:setRotation(activeDownload.busyRotation)
    activeDownload.busySpinEvent = scheduleEvent(spin, BUSY_INDICATOR_ROTATION_DELAY)
  end

  spin()
end

local function setDownloadBusy(status, detail)
  if not activeDownload then
    return
  end

  activeDownload.busyStatus = status
  activeDownload.busyDetail = detail
  startBusyIndicatorSpin()
  if activeDownload.busyEvent then
    return
  end

  local frames = { '', '.', '..', '...' }

  local function tick()
    if not activeDownload or not activeDownload.window then
      return
    end

    activeDownload.busyFrame = ((activeDownload.busyFrame or 0) % #frames) + 1
    renderDownloadStatus((activeDownload.busyStatus or status) .. frames[activeDownload.busyFrame], nil, activeDownload.busyDetail)
    updateDownloadBar(activeDownload.window, 0)
    if activeDownload.window.busyIndicator then
      activeDownload.window.busyIndicator:setVisible(true)
      activeDownload.window.busyIndicator:raise()
    end
    activeDownload.busyEvent = scheduleEvent(tick, 350)
  end

  tick()
end

local function logArchiveHeartbeat(message)
  if not activeDownload then
    return
  end

  local now = g_clock.millis()
  if activeDownload.lastArchiveHeartbeat and now - activeDownload.lastArchiveHeartbeat < 5000 then
    return
  end

  activeDownload.lastArchiveHeartbeat = now
  logInfo(message)
end

local function finishDownload(ok, message)
  local callback = activeDownload and activeDownload.callback
  if ok then
    logInfo(message or 'Asset download finished.')
  elseif message then
    logError(message)
  end
  stopDownloadBusy()
  if activeDownload and activeDownload.window then
    activeDownload.window:destroy()
  end
  activeDownload = nil
  if callback then
    callback(ok, ok and message or userFacingError(message))
  end
end

local function cancelDownload()
  if activeDownload then
    activeDownload.canceled = true
    if activeDownload.operationId then
      HTTP.cancel(activeDownload.operationId)
    end
    activeDownload.window = nil
  end
  finishDownload(false, tr('Download canceled.'))
end

local function verifyDownloadedSha256(downloadPath, expectedSha256)
  if not expectedSha256 or expectedSha256 == '' then
    return true
  end

  local ok, contents = pcall(function()
    return g_resources.readFileContents('/downloads/' .. downloadPath)
  end)
  if not ok then
    return false, contents
  end

  local actualSha256 = g_crypt.sha256(contents)
  if actualSha256 ~= expectedSha256 then
    return false, string.format('Invalid SHA-256 for %s. Expected %s, got %s.', downloadPath, expectedSha256, actualSha256)
  end

  return true
end

local function verifyInstalledSha256(destinationPath, expectedSha256, deleteOnMismatch)
  if not expectedSha256 or expectedSha256 == '' then
    return true
  end

  local actualSha256
  if g_resources.fileSha256InWorkDir then
    actualSha256 = g_resources.fileSha256InWorkDir(destinationPath)
  else
    actualSha256 = g_resources.fileSha256('/' .. destinationPath)
  end
  if actualSha256 ~= expectedSha256 then
    if deleteOnMismatch ~= false then
      g_resources.deleteFile('/' .. destinationPath)
    end
    return false, string.format('Invalid installed SHA-256 for %s. Expected %s, got %s.', destinationPath, expectedSha256, actualSha256)
  end

  return true
end

local function writeDownloadedFile(config, downloadPath, destinationPath, decompressLzma)
  if shouldInstallInWorkDir(config) and g_resources.writeDownloadedFileToWorkDir then
    return g_resources.writeDownloadedFileToWorkDir(downloadPath, destinationPath, decompressLzma == true)
  end
  return g_resources.writeDownloadedFile(downloadPath, destinationPath, decompressLzma == true)
end

local function extractDownloadedArchive(config, downloadPath, destinationPath, entryPrefix, stripPrefix)
  if shouldInstallInWorkDir(config) and g_resources.extractDownloadedArchiveToWorkDir then
    return g_resources.extractDownloadedArchiveToWorkDir(downloadPath, destinationPath, entryPrefix or '', stripPrefix == true)
  end
  return g_resources.extractDownloadedArchive(downloadPath, destinationPath, entryPrefix or '', stripPrefix == true)
end

local function installDownloadedFile(config, downloadPath, destinationPath, decompressLzma, expectedFileSha256, allowHashMismatch, allowHashMismatchReason)
  if not writeDownloadedFile(config, downloadPath, destinationPath, decompressLzma) then
    return false, 'Unable to write downloaded asset: ' .. destinationPath
  end

  local ok, hashError = verifyInstalledSha256(destinationPath, expectedFileSha256, not allowHashMismatch)
  if not ok and allowHashMismatch and not hashError:find(HTTP_NOT_FOUND_SHA256, 1, true) then
    logWarning(hashError .. ' Continuing because ' .. (allowHashMismatchReason or 'allowRawFallbackHashMismatch is enabled') .. '.')
    return true
  end

  return ok, hashError
end

local function writeManifestHashIdentifier(config, version, sha256)
  if not sha256 or sha256 == '' then
    return true
  end

  local destinationPath = string.format('data/things/%d/assets.json.sha256', version)
  if not writeInstallFile(config, destinationPath, sha256 .. '\n') then
    return false, 'Unable to write asset hash identifier: ' .. destinationPath
  end

  logInfo(string.format('Asset hash identifier install path: %s.', physicalInstallPath(destinationPath)))
  return true
end

local function installDownloadedArchive(config, downloadPath, destinationPath, entryPrefix, stripPrefix, expectedPath, expectedSha256)
  if not extractDownloadedArchive(config, downloadPath, destinationPath, entryPrefix, stripPrefix) then
    return false, 'Unable to extract downloaded archive: ' .. destinationPath
  end

  if expectedPath then
    return verifyInstalledSha256(expectedPath, expectedSha256)
  end

  return true
end

local function shouldInstallManifestEntry(entry, version, config)
  local localfile = entry.localfile or entry.file or entry.path
  if type(localfile) ~= 'string' then
    return nil
  end

  if startsWith(localfile, 'assets/') then
    local relativePath = localfile:sub(#'assets/' + 1)
    local archive = isArchivePath(localfile) or isArchivePath(entry.url)
    local basePath = string.format('data/things/%d', version)
    return {
      sourcePath = localfile,
      relativePath = relativePath,
      destinationPath = archive and (basePath .. '/' .. parentPath(relativePath)) or (basePath .. '/' .. relativePath),
      extractedFilePath = archive and not isArchivePath(localfile) and (basePath .. '/' .. localfile:sub(#'assets/' + 1)) or nil,
      archive = archive,
      sound = false
    }
  end

  if config.installSounds ~= false and startsWith(localfile, 'sounds/') then
    local relativePath = localfile:sub(#'sounds/' + 1)
    local archive = isArchivePath(localfile) or isArchivePath(entry.url)
    local basePath = string.format('data/sounds/%d', version)
    return {
      sourcePath = localfile,
      relativePath = relativePath,
      destinationPath = archive and (basePath .. '/' .. parentPath(relativePath)) or (basePath .. '/' .. relativePath),
      extractedFilePath = archive and not isArchivePath(localfile) and (basePath .. '/' .. localfile:sub(#'sounds/' + 1)) or nil,
      archive = archive,
      sound = true
    }
  end

  local archive = isArchivePath(localfile) or isArchivePath(entry.url)
  if config.installPackagedFiles ~= false and archive and matchesAnyPrefix(localfile, config.packagedFilePrefixes) then
    local destination = string.format(config.packagedFilesDestination or '', version)
    local finalPath = isArchivePath(localfile) and stripArchiveExtension(localfile) or localfile
    return {
      sourcePath = localfile,
      relativePath = localfile,
      destinationPath = joinUrl(destination, parentPath(localfile)),
      extractedFilePath = not isArchivePath(localfile) and joinUrl(destination, finalPath) or nil,
      archive = true,
      packaged = true
    }
  end

  return nil
end

local function buildManifestDownload(entry, descriptor, selectedEntry, usePackedUrl)
  local sourcePath = selectedEntry.sourcePath
  local downloadUrl = joinUrl(descriptor.baseUrl, sourcePath)
  local decompressLzma = false
  local extractArchive = selectedEntry.archive == true
  local expectedDownloadSha256
  local expectedFileSha256

  if usePackedUrl and entry.url then
    sourcePath = entry.url
    downloadUrl = joinUrl(descriptor.baseUrl, sourcePath)
    expectedDownloadSha256 = entry.packedhash
    extractArchive = isArchivePath(sourcePath)
    if extractArchive then
      expectedFileSha256 = selectedEntry.extractedFilePath and entry.unpackedhash or nil
    elseif endsWith(sourcePath, '.lzma') and entry.unpack ~= false then
      decompressLzma = true
      expectedFileSha256 = entry.unpackedhash
    else
      expectedFileSha256 = entry.packedhash
    end
  else
    extractArchive = isArchivePath(sourcePath)
    if extractArchive then
      expectedDownloadSha256 = entry.packedhash or entry.unpackedhash
      expectedFileSha256 = selectedEntry.extractedFilePath and entry.unpackedhash or nil
    elseif entry.unpack == false then
      expectedDownloadSha256 = entry.packedhash
      expectedFileSha256 = entry.packedhash
    else
      expectedDownloadSha256 = entry.unpackedhash or entry.packedhash
      expectedFileSha256 = entry.unpackedhash or entry.packedhash
    end
  end

  return {
    url = downloadUrl,
    sourcePath = sourcePath,
    decompressLzma = decompressLzma,
    extractArchive = extractArchive,
    archiveEntryPrefix = entry.archiveEntryPrefix or entry.archivePrefix or '',
    stripArchivePrefix = entry.stripArchivePrefix,
    expectedDownloadSha256 = expectedDownloadSha256,
    expectedFileSha256 = expectedFileSha256,
    destinationPath = selectedEntry.destinationPath,
    extractedFilePath = selectedEntry.extractedFilePath
  }
end

local function installManifestEntries(config, descriptor, files, index, installed, total, callback)
  if activeDownload and activeDownload.canceled then
    return callback(false, tr('Download canceled.'))
  end

  local entry = files[index]
  if not entry then
    return callback(true, string.format('Installed %d assets.', installed))
  end

  local selectedEntry = shouldInstallManifestEntry(entry, descriptor.version, descriptor)
  if not selectedEntry then
    return installManifestEntries(config, descriptor, files, index + 1, installed, total, callback)
  end

  local preferPacked = descriptor.preferPackedManifestUrls
  if preferPacked == nil then
    preferPacked = config.preferPackedManifestUrls
  end
  local directDownload = buildManifestDownload(entry, descriptor, selectedEntry, preferPacked)
  local fallbackDownload
  if config.allowRawFallbackHashMismatch ~= false and not directDownload.extractArchive and not isLzmaPath(directDownload.sourcePath) then
    directDownload.allowHashMismatch = true
    directDownload.allowHashMismatchReason = 'allowRawFallbackHashMismatch is enabled'
  end
  if entry.url then
    fallbackDownload = buildManifestDownload(entry, descriptor, selectedEntry, not preferPacked)
    if fallbackDownload.sourcePath == directDownload.sourcePath then
      fallbackDownload = nil
    elseif config.allowRawFallbackHashMismatch ~= false and not isArchivePath(fallbackDownload.sourcePath) and not isLzmaPath(fallbackDownload.sourcePath) then
      fallbackDownload.allowHashMismatch = true
      fallbackDownload.allowHashMismatchReason = 'allowRawFallbackHashMismatch is enabled'
    end
  end
  if directDownload.expectedFileSha256 and g_resources.fileSha256('/' .. directDownload.destinationPath) == directDownload.expectedFileSha256 then
    return installManifestEntries(config, descriptor, files, index + 1, installed, total, callback)
  end

  local function downloadEntry(downloadInfo, nextFallback, retriesLeft)
    local fileName = string.format('asset-downloads/%d/%04d-%s', descriptor.version, index, g_resources.getFileName(downloadInfo.sourcePath))
    setDownloadProgress(string.format('Downloading assets for client %s', versionLabel(descriptor.version)), overallProgress(index, total, 0))

    activeDownload.operationId = httpDownload(config, downloadInfo.url, fileName, function(path, checksum, err)
      if not activeDownload then
        return
      end

      activeDownload.operationId = nil

      if activeDownload.canceled then
        return callback(false, tr('Download canceled.'))
      end

      if err then
        if retriesLeft > 0 then
          return downloadEntry(downloadInfo, nextFallback, retriesLeft - 1)
        end
        if nextFallback then
          if config.allowMissingPackedRawFallback ~= false and isNotFoundError(err) and isPackedToRawFallback(downloadInfo, nextFallback) then
            nextFallback.allowHashMismatch = true
            nextFallback.allowHashMismatchReason = 'the packed asset is missing and allowMissingPackedRawFallback is enabled'
          end
          logWarning(string.format('Download failed for %s: %s. Trying %s.', downloadInfo.sourcePath, err, nextFallback.sourcePath))
          return downloadEntry(nextFallback, nil, config.retries or 0)
        end
        return callback(false, err)
      end

      local ok, hashError = verifyDownloadedSha256(path, downloadInfo.expectedDownloadSha256)
      if not ok then
        if nextFallback then
          if config.allowMissingPackedRawFallback ~= false and isMissingPackedHashError(hashError) and isPackedToRawFallback(downloadInfo, nextFallback) then
            nextFallback.allowHashMismatch = true
            nextFallback.allowHashMismatchReason = 'the packed asset is missing and allowMissingPackedRawFallback is enabled'
          end
          logWarning(hashError .. ' Trying ' .. nextFallback.sourcePath .. '.')
          return downloadEntry(nextFallback, nil, config.retries or 0)
        end
        if downloadInfo.allowHashMismatch and not hashError:find(HTTP_NOT_FOUND_SHA256, 1, true) then
          logWarning(hashError .. ' Continuing because ' .. (downloadInfo.allowHashMismatchReason or 'allowRawFallbackHashMismatch is enabled') .. '.')
          ok = true
        else
          return callback(false, hashError)
        end
      end

      if not ok then
        return callback(false, hashError)
      end

      if downloadInfo.extractArchive then
        ok, hashError = installDownloadedArchive(
          config,
          path,
          downloadInfo.destinationPath,
          downloadInfo.archiveEntryPrefix,
          downloadInfo.stripArchivePrefix,
          downloadInfo.extractedFilePath,
          downloadInfo.expectedFileSha256
        )
      else
        ok, hashError = installDownloadedFile(config, path, downloadInfo.destinationPath, downloadInfo.decompressLzma, downloadInfo.expectedFileSha256, downloadInfo.allowHashMismatch, downloadInfo.allowHashMismatchReason)
      end
      if not ok then
        if nextFallback then
          logWarning(hashError .. ' Trying ' .. nextFallback.sourcePath .. '.')
          return downloadEntry(nextFallback, nil, config.retries or 0)
        end
        return callback(false, hashError)
      end

      return installManifestEntries(config, descriptor, files, index + 1, installed + 1, total, callback)
    end, function(progress, speed)
      setDownloadProgress(string.format('Downloading assets for client %s', versionLabel(descriptor.version)), overallProgress(index, total, progress))
    end)
  end

  downloadEntry(directDownload, fallbackDownload, config.retries or 0)
end

local function fetchManifestSha256(config, descriptor, callback)
  if descriptor.manifestSha256 or not descriptor.manifestSha256Url then
    return callback(descriptor.manifestSha256)
  end

  activeDownload.operationId = httpGet(config, descriptor.manifestSha256Url, function(data, err)
    if not activeDownload then
      return
    end
    activeDownload.operationId = nil
    if err then
      return callback(nil)
    end
    data = tostring(data or ''):match('^%s*(.-)%s*$')
    callback(data ~= '' and data or nil)
  end)
end

local function installFromManifest(config, descriptor, callback)
  if not descriptor.manifestUrl then
    return callback(false, 'No asset manifest URL configured.')
  end

  setDownloadProgress(string.format('Fetching assets manifest for client %s', versionLabel(descriptor.version)), 0)
  activeDownload.operationId = httpGet(config, descriptor.manifestUrl, function(data, err)
    if not activeDownload then
      return
    end
    activeDownload.operationId = nil
    if err then
      return callback(false, err)
    end

    fetchManifestSha256(config, descriptor, function(expectedSha256)
      local actualSha256 = g_crypt.sha256(data)
      if expectedSha256 then
        if actualSha256 ~= expectedSha256 then
          local hashError = string.format('Invalid assets manifest SHA-256 for %s. Expected %s, got %s.', descriptor.manifestUrl, expectedSha256, actualSha256)
          if descriptor.strictManifestSha256 or config.strictManifestSha256 then
            return callback(false, hashError)
          end
          logWarning(hashError .. ' Continuing because strictManifestSha256 is disabled.')
        end
      end

      local ok, manifest = pcall(function()
        return json.decode(data)
      end)
      if not ok or type(manifest) ~= 'table' or type(manifest.files) ~= 'table' then
        return callback(false, 'Invalid assets manifest.')
      end

      local wroteHashIdentifier, writeHashError = writeManifestHashIdentifier(config, descriptor.version, actualSha256)
      if not wroteHashIdentifier then
        return callback(false, writeHashError)
      end

      installManifestEntries(config, descriptor, manifest.files, 1, 0, #manifest.files, callback)
    end)
  end)
end

local function installFromArchive(config, descriptor, callback)
  if not descriptor.archiveUrl then
    return callback(false, 'No asset archive URL configured.')
  end

  local archiveName = string.format('asset-downloads/%d/%s', descriptor.version, g_resources.getFileName(descriptor.archiveUrl))
  logInfo(string.format('Downloading asset archive for client %s from %s.', versionLabel(descriptor.version), descriptor.archiveUrl))
  setDownloadBusy('Downloading files', string.format('Client %s archive', versionLabel(descriptor.version)))

  activeDownload.operationId = httpDownload(config, descriptor.archiveUrl, archiveName, function(path, checksum, err)
    if not activeDownload then
      return
    end

    activeDownload.operationId = nil

    if err then
      return callback(false, err)
    end

    logInfo(string.format('Asset archive download completed for client %s. Verifying archive.', versionLabel(descriptor.version)))
    setDownloadBusy('Verifying downloaded archive', string.format('Client %s', versionLabel(descriptor.version)))
    local ok, hashError = verifyDownloadedSha256(path, descriptor.archiveSha256)
    if not ok then
      return callback(false, hashError)
    end

    local label = versionLabel(descriptor.version)
    local thingsDestination = string.format('data/things/%d', descriptor.version)
    local soundsDestination = string.format('data/sounds/%d', descriptor.version)

    local function finishArchiveInstall()
      setDownloadProgress('Finishing asset install', 100)
      logInfo(string.format('Finished archive install for client %s.', label))
      callback(true)
    end

    local function extractArchiveExtras()
      if descriptor.installArchiveExtras == false or not descriptor.archiveExtraPrefixes then
        return finishArchiveInstall()
      end

      local prefixes = descriptor.archiveExtraPrefixes
      if type(prefixes) == 'string' then
        prefixes = { prefixes }
      end

      local destination = descriptor.archiveExtrasDestination or ''
      destination = string.format(destination, descriptor.version)

      local function extractNextExtra(extraIndex)
        local prefix = prefixes[extraIndex]
        if not prefix then
          return finishArchiveInstall()
        end

        logInfo(string.format('Extracting archive extras for client %s: %s. This can take a while.', label, prefix))
        setDownloadBusy('Extracting files', string.format('Client %s extras: %s', label, prefix))
        scheduleDownloadStep(function()
          extractDownloadedArchive(config, path, destination, prefix, false)
          logInfo(string.format('Finished archive extras for client %s: %s.', label, prefix))
          logInfo(string.format('Archive extras install path: %s.', physicalInstallPath(destination)))
          extractNextExtra(extraIndex + 1)
        end)
      end

      extractNextExtra(1)
    end

    local function extractSoundAssets()
      if descriptor.installSounds == false then
        return extractArchiveExtras()
      end

      logInfo(string.format('Extracting sound assets for client %s. This can take a while.', label))
      setDownloadBusy('Extracting files', string.format('Client %s sounds', label))
      scheduleDownloadStep(function()
        if extractDownloadedArchive(config, path, soundsDestination, descriptor.archiveSoundsPrefix or 'sounds', true) then
          logInfo(string.format('Finished extracting sound assets for client %s.', label))
          logInfo(string.format('Sound assets install path: %s.', physicalInstallPath(soundsDestination)))
        else
          logWarning(string.format('No sound assets were extracted for client %s.', label))
        end
        extractArchiveExtras()
      end)
    end

    local function extractAssetHashIdentifier()
      logInfo(string.format('Extracting asset hash identifier for client %s.', label))
      extractDownloadedArchive(config, path, thingsDestination, 'assets.json.sha256', false)
      extractSoundAssets()
    end

    logInfo(string.format('Extracting things assets for client %s. This can take a while.', label))
    setDownloadBusy('Extracting files', string.format('Client %s assets', label))
    scheduleDownloadStep(function()
      if not extractDownloadedArchive(config, path, thingsDestination, descriptor.archiveThingsPrefix or 'assets', true) then
        return callback(false, 'Unable to extract assets from archive.')
      end

      logInfo(string.format('Finished extracting things assets for client %s.', label))
      logInfo(string.format('Things install path: %s.', physicalInstallPath(thingsDestination)))
      extractAssetHashIdentifier()
    end)
  end, function(progress, speed)
    setDownloadBusy('Downloading files', string.format('Client %s archive', versionLabel(descriptor.version)))
    logArchiveHeartbeat('Archive download still in progress. GitHub did not provide a reliable total size, so the UI is in indeterminate mode.')
  end)
end

local function collectPackagedFiles(descriptor, tree)
  local packagedFiles = {}
  if type(tree) ~= 'table' or type(tree.tree) ~= 'table' then
    return packagedFiles
  end

  for _, item in ipairs(tree.tree) do
    local path = item.path
    if item.type == 'blob' and isArchivePath(path) and matchesAnyPrefix(path, descriptor.packagedFilePrefixes) then
      packagedFiles[#packagedFiles + 1] = path
    end
  end

  table.sort(packagedFiles)
  return packagedFiles
end

local function installPackagedFileList(config, descriptor, files, index, callback)
  local path = files[index]
  if not path then
    return callback(true)
  end

  if activeDownload and activeDownload.canceled then
    return callback(false, tr('Download canceled.'))
  end

  local url = joinUrl(descriptor.baseUrl, path)
  local downloadName = string.format('asset-downloads/%d/packaged-%04d-%s', descriptor.version, index, g_resources.getFileName(path))
  logInfo(string.format('Downloading packaged file %d/%d for client %s: %s.', index, #files, versionLabel(descriptor.version), path))
  setDownloadBusy('Downloading files', string.format('Client %s packaged files', versionLabel(descriptor.version)))

  activeDownload.operationId = httpDownload(config, url, downloadName, function(downloadPath, checksum, err)
    if not activeDownload then
      return
    end

    activeDownload.operationId = nil

    if err then
      return callback(false, err)
    end

    local destination = string.format(descriptor.packagedFilesDestination or '', descriptor.version)
    local destinationPath = joinUrl(destination, parentPath(path))
    logInfo(string.format('Extracting packaged file %d/%d for client %s: %s.', index, #files, versionLabel(descriptor.version), path))
    setDownloadBusy('Extracting files', string.format('Client %s packaged files', versionLabel(descriptor.version)))

    scheduleDownloadStep(function()
      local ok, extractError = installDownloadedArchive(config, downloadPath, destinationPath, '', false)
      if not ok then
        if descriptor.packagedFilesRequired then
          return callback(false, extractError)
        end
        logWarning(string.format(
          'Skipping optional packaged file %d/%d for client %s: %s (%s).',
          index,
          #files,
          versionLabel(descriptor.version),
          path,
          extractError or 'unable to extract archive'
        ))
        return installPackagedFileList(config, descriptor, files, index + 1, callback)
      end

      logInfo(string.format('Finished packaged file %d/%d for client %s: %s.', index, #files, versionLabel(descriptor.version), path))
      logInfo(string.format('Packaged file install path: %s.', physicalInstallPath(destinationPath)))
      installPackagedFileList(config, descriptor, files, index + 1, callback)
    end)
  end, function(progress, speed)
    setDownloadBusy('Downloading files', string.format('Client %s packaged files', versionLabel(descriptor.version)))
    logArchiveHeartbeat(string.format('Packaged file download still in progress (%d/%d): %s.', index, #files, path))
  end)
end

local function installPackagedFiles(config, descriptor, callback)
  if descriptor.installPackagedFiles == false or not descriptor.treeUrl or not descriptor.baseUrl then
    return callback(true)
  end

  activeDownload.operationId = httpGetJSON(config, descriptor.treeUrl, function(data, err)
    if not activeDownload then
      return
    end

    activeDownload.operationId = nil

    if err then
      if descriptor.packagedFilesRequired then
        return callback(false, err)
      end
      return callback(true)
    end

    local packagedFiles = collectPackagedFiles(descriptor, data)
    if #packagedFiles == 0 then
      return callback(true)
    end

    installPackagedFileList(config, descriptor, packagedFiles, 1, callback)
  end)
end

local function installDescriptor(config, descriptor, callback)
  local archiveAttempted = false

  local function finishWithPackagedFiles(ok, message)
    if not ok then
      return callback(false, message)
    end

    installPackagedFiles(config, descriptor, function(packagedOk, packagedMessage)
      callback(packagedOk, packagedMessage or message)
    end)
  end

  local function archiveFallback(nextCallback)
    archiveAttempted = true
    installFromArchive(config, descriptor, nextCallback)
  end

  local function manifestFallback()
    installFromManifest(config, descriptor, function(ok, message)
      local allowArchiveFallback = descriptor.fallbackToArchiveOnManifestFailure
      if allowArchiveFallback == nil then
        allowArchiveFallback = config.fallbackToArchiveOnManifestFailure
      end
      if ok or not descriptor.archiveUrl or archiveAttempted or not allowArchiveFallback then
        return finishWithPackagedFiles(ok, message)
      end
      logWarning((message or 'Asset manifest install failed.') .. ' Trying archive fallback.')
      archiveFallback(finishWithPackagedFiles)
    end)
  end

  local preferArchive = descriptor.preferArchive
  if preferArchive == nil then
    preferArchive = config.preferArchive
  end

  if preferArchive and descriptor.archiveUrl then
    return archiveFallback(function(ok, message)
      if ok or not descriptor.manifestUrl then
        return finishWithPackagedFiles(ok, message)
      end
      logWarning((message or 'Archive asset install failed.') .. ' Falling back to asset manifest.')
      manifestFallback()
    end)
  end

  if descriptor.manifestUrl then
    return manifestFallback()
  end

  if descriptor.archiveUrl then
    return archiveFallback(finishWithPackagedFiles)
  end

  finishWithPackagedFiles(false, 'No assets source found.')
end

local function resolveFromCustomManifest(config, version, callback)
  if not config.manifestUrl then
    return callback(nil)
  end

  activeDownload.operationId = httpGetJSON(config, config.manifestUrl, function(data, err)
    if not activeDownload then
      return
    end
    activeDownload.operationId = nil
    if err then
      return callback(nil, err)
    end

    local descriptor = normalizeDescriptor(config, version, readVersionEntry(data, version))
    callback(descriptor)
  end)
end

local function resolveFromGitHubReleases(config, version, callback)
  if not config.releasesUrl then
    return callback(nil)
  end

  local cacheKey = tostring(config.releasesUrl or config.repository or '')
  if releasesCache[cacheKey] then
    local release = findReleaseForVersion(releasesCache[cacheKey], version)
    return callback(release and descriptorFromRelease(config, version, release) or nil)
  end

  activeDownload.operationId = httpGetJSON(config, config.releasesUrl, function(data, err)
    if not activeDownload then
      return
    end
    activeDownload.operationId = nil
    if err then
      return callback(nil, err)
    end

    releasesCache[cacheKey] = data
    local release = findReleaseForVersion(releasesCache[cacheKey], version)
    callback(release and descriptorFromRelease(config, version, release) or nil)
  end)
end

local function resolveDescriptor(config, version, callback)
  resolveFromCustomManifest(config, version, function(descriptor)
    if descriptor then
      return callback(descriptor)
    end
    resolveFromGitHubReleases(config, version, callback)
  end)
end

function isEnabled()
  return cloneConfig().enabled ~= false
end

function isClientVersionInstalled(version)
  version = tonumber(version)
  if not version then
    return false
  end

  if version >= 1281 then
    return hasInstalledModernClientFiles(version)
  end

  return g_resources.fileExists(string.format('/data/things/%d/Tibia.dat', version)) and
         g_resources.fileExists(string.format('/data/things/%d/Tibia.spr', version))
end

function getInstalledClientVersions()
  local installed = {}
  local paths = { '/data/things/' }
  for _, path in ipairs(paths) do
    for _, dirItem in ipairs(g_resources.listDirectoryFiles(path)) do
      if tonumber(dirItem) then
        installed[tostring(dirItem)] = true
      end
    end
  end
  return installed
end

function ensureClientVersion(version, callback)
  version = tonumber(version)
  if not version then
    return callback(false, 'Invalid client version.')
  end

  local config = cloneConfig()
  if config.enabled == false or isClientVersionInstalled(version) then
    return callback(true)
  end

  if activeDownload then
    return callback(false, 'Another asset download is already running.')
  end

  local promptBox
  local message = string.format('Assets for client %s are not installed.\nDownload them now?', versionLabel(version))
  local function startDownload()
    if promptBox then
      local box = promptBox
      promptBox = nil
      box:ok()
    end

    if activeDownload then
      return
    end

    activeDownload = {
      version = version,
      callback = callback,
      canceled = false
    }

    logInfo(string.format('Starting asset download for client %s.', versionLabel(version)))
    activeDownload.window = createDownloadWindow()
    connect(activeDownload.window, {
      onCancel = cancelDownload
    })

    resolveDescriptor(config, version, function(descriptor, err)
      if not descriptor then
        return finishDownload(false, err or string.format('No assets source found for client %s.', versionLabel(version)))
      end

      installDescriptor(config, descriptor, function(ok, installError)
        if not ok then
          return finishDownload(false, installError or 'Unable to install client assets.')
        end

        if not hasModernClientFiles(version) then
          return finishDownload(false, 'Assets were downloaded but the client files are still incomplete. Missing catalog-content.json, assets.json.sha256, or required catalog files.')
        end

        local markerPath = completeMarkerPath(version)
        if not installFileExists(markerPath) then
          markClientVersionInstalled(config, version)
          if not installFileExists(markerPath) then
            logWarning('Assets were downloaded but the install marker could not be written.')
          end
        end

        logInfo(string.format('Client %s installed at: %s.', versionLabel(version), physicalInstallPath(string.format('data/things/%d', version))))
        if config.installSounds ~= false then
          logInfo(string.format('Client %s sounds installed at: %s.', versionLabel(version), physicalInstallPath(string.format('data/sounds/%d', version))))
        end
        if config.installArchiveExtras ~= false then
          logInfo(string.format('Client %s extra files installed at: %s.', versionLabel(version), physicalInstallPath('bin')))
        end

        finishDownload(true)
      end)
    end)
  end

  local function cancelPrompt()
    if promptBox then
      local box = promptBox
      promptBox = nil
      box:cancel()
    end
    callback(false, tr('Assets are required for this client version.'))
  end

  promptBox = displayGeneralBox(tr('Missing Assets'), message, {
    { text = tr('Download'), callback = startDownload },
    { text = tr('Cancel'), callback = cancelPrompt }
  }, startDownload, cancelPrompt)
end
