-- Shopkeeper Main Addon File
-- Last Updated August 4, 2014
-- Written July 2014 by Dan Stone (@khaibit) - dankitymao@gmail.com
-- Released under terms in license accompanying this file.
-- Distribution without license is prohibited!

-- Workaround because GetDisplayName() is broken
-- Maybe not needed anymore, will test
function Shopkeeper.GetAccountName()
  local acctName = GetDisplayName()
  if acctName == nil or acctName == "" then
    -- Hopefully, they're in a guild and we can use GetPlayerGuildMemberIndex
    if GetNumGuilds() > 0 then
      acctName = GetGuildMemberInfo(GetGuildId(1), GetPlayerGuildMemberIndex(GetGuildId(1)))
    -- But if they're in no guilds, we have to use the AccountName CVar, which only works
    -- if they saved their account name on the login page.  Best we can do with patch 1.2.3
    -- breaking GetDisplayName(), but if they aren't in any guilds, why are they using this
    -- addon anyway?
    else
      acctName = "@" .. GetCVar("AccountName")
    end
  end
  return acctName
end

-- Translate from the i18n table
function Shopkeeper.translate(stringName)
  local result = Shopkeeper.i18n.localized[stringName]
  assert(result, ("The id %q was not found in the current locale"):format(stringName))
  return result
end

function Shopkeeper.localizedNumber(numberValue)
  local stringPrice = numberValue
  -- Insert thousands separators for the price
  -- local stringPrice = numberValue
  local subString = "%1" .. Shopkeeper.translate("thousandsSep") .."%2"
  while true do
    stringPrice, k = string.gsub(stringPrice, "^(-?%d+)(%d%d%d)", subString)
    if (k == 0) then break end
  end

  return stringPrice
end

-- Create a textual representation of a time interval
-- (X and Y) or Z in LUA is the equivalent of C-style
-- ternary syntax X ? Y : Z so long as Y is not false or nil
function Shopkeeper.textTimeSince(theTime, useLowercase)
  local secsSince = GetTimeStamp() - theTime
  if secsSince < 60 then
    return ((useLowercase and zo_strformat(Shopkeeper.translate('timeSecondsAgoLC'), secsSince)) or
             zo_strformat(Shopkeeper.translate('timeSecondsAgo'), secsSince))
  elseif secsSince < 3600 then
    return ((useLowercase and zo_strformat(Shopkeeper.translate('timeMinutesAgoLC'), math.floor(secsSince / 60.0))) or
             zo_strformat(Shopkeeper.translate('timeMinutesAgo'), math.floor(secsSince / 60.0)))
  elseif secsSince < 86400 then
    return ((useLowercase and zo_strformat(Shopkeeper.translate('timeHoursAgoLC'), math.floor(secsSince / 3600.0))) or
             zo_strformat(Shopkeeper.translate('timeHoursAgo'), math.floor(secsSince / 3600.0)))
  else
    return ((useLowercase and zo_strformat(Shopkeeper.translate('timeDaysAgoLC'), math.floor(secsSince / 86400.0))) or
             zo_strformat(Shopkeeper.translate('timeDaysAgo'), math.floor(secsSince / 86400.0)))
  end
end

-- A utility function to grab all the keys of the sound table
-- to populate the options dropdown
function Shopkeeper.soundKeys()
  local keyList = {}
  local keyIndex = 0
  for i = 1, #Shopkeeper.alertSounds do
    keyIndex = keyIndex + 1
    keyList[keyIndex] = Shopkeeper.alertSounds[i].name
  end

  return keyList
end

-- A utility function to find the key associated with a given value in
-- the sounds table.  Best we can do is a linear search unfortunately,
-- but it's a small table.
function Shopkeeper.searchSounds(sound)
  for i, theSound in ipairs(Shopkeeper.alertSounds) do
    if theSound.sound == sound then return theSound.name end
  end

  -- If we hit this point, we didn't find what we were looking for
  return nil
end

function Shopkeeper.searchSoundNames(name)
  for i,theSound in ipairs(Shopkeeper.alertSounds) do
    if theSound.name == name then return theSound.sound end
  end
end

-- Handle the OnMoveStop event for the window
function Shopkeeper.OnWindowMoveStop()
  Shopkeeper.savedVariables.winLeft = ShopkeeperWindow:GetLeft()
  Shopkeeper.savedVariables.winTop = ShopkeeperWindow:GetTop()
  Shopkeeper.savedVariables.miniWinLeft = ShopkeeperMiniWindow:GetLeft()
  Shopkeeper.savedVariables.miniWinTop = ShopkeeperMiniWindow:GetTop()
end

function Shopkeeper.OnStatsWindowMoveStop()
  Shopkeeper.savedVariables.statsWinLeft = ShopkeeperStatsWindow:GetLeft()
  Shopkeeper.savedVariables.statsWinTop = ShopkeeperStatsWindow:GetTop()
end

-- Restore the window position from saved vars
function Shopkeeper:RestoreWindowPosition()
  local left = self.savedVariables.winLeft
  local top = self.savedVariables.winTop
  local statsLeft = self.savedVariables.statsWinLeft
  local statsTop = self.savedVariables.statsWinTop
  local miniLeft = self.savedVariables.miniWinLeft
  local miniTop = self.savedVariables.miniWinTop

  ShopkeeperWindow:ClearAnchors()
  ShopkeeperStatsWindow:ClearAnchors()
  ShopkeeperMiniWindow:ClearAnchors()
  ShopkeeperWindow:SetAnchor(TOPLEFT, GuiRoot, TOPLEFT, left, top)
  ShopkeeperStatsWindow:SetAnchor(TOPLEFT, GuiRoot, TOPLEFT, statsLeft, statsTop)
  ShopkeeperMiniWindow:SetAnchor(TOPLEFT, GuiRoot, TOPLEFT, miniLeft, miniTop)
end

-- Handle the changing of main window font settings
function Shopkeeper:UpdateFonts()
  local LMP = LibStub("LibMediaProvider-1.0")
  if LMP then
    local font = LMP:Fetch('font', Shopkeeper.savedVariables.windowFont)
    local look = string.format('%s|16', font)
    local titleLook = string.format('%s|22', font)
    local headerLook = string.format('%s|20', font)
    local miniLook = string.format('%s|12', font)
    local miniTitleLook = string.format('%s|18', font)
    local miniHeaderLook = string.format('%s|16', font)
    ShopkeeperWindowSearchLabel:SetFont(look)
    ShopkeeperWindowSearchBox:SetFont(look)
    ShopkeeperWindowTitle:SetFont(titleLook)
    ShopkeeperWindowBuyer:SetFont(headerLook)
    ShopkeeperWindowGuild:SetFont(headerLook)
    ShopkeeperWindowItemName:SetFont(headerLook)
    ShopkeeperWindowSellTime:SetFont(headerLook)
    ShopkeeperWindowPrice:SetFont(headerLook)
    ShopkeeperSwitchViewButton:SetFont(look)
    ShopkeeperPriceSwitchButton:SetFont(look)
    ShopkeeperResetButton:SetFont(look)
    ShopkeeperRefreshButton:SetFont(look)
    ShopkeeperMiniWindowSearchLabel:SetFont(miniLook)
    ShopkeeperMiniWindowSearchBox:SetFont(miniLook)
    ShopkeeperMiniWindowTitle:SetFont(miniTitleLook)
    ShopkeeperMiniWindowGuild:SetFont(miniHeaderLook)
    ShopkeeperMiniWindowItemName:SetFont(miniHeaderLook)
    ShopkeeperMiniWindowSellTime:SetFont(miniHeaderLook)
    ShopkeeperMiniWindowPrice:SetFont(miniHeaderLook)
    ShopkeeperMiniSwitchViewButton:SetFont(miniLook)
    ShopkeeperMiniPriceSwitchButton:SetFont(miniLook)
    ShopkeeperMiniResetButton:SetFont(miniLook)
    ShopkeeperMiniRefreshButton:SetFont(miniLook)

    ShopkeeperStatsWindowTitle:SetFont(titleLook)
    ShopkeeperStatsWindowItemsSoldLabel:SetFont(look)
    ShopkeeperStatsWindowTotalGoldLabel:SetFont(look)
    ShopkeeperStatsWindowBiggestSaleLabel:SetFont(look)
    ShopkeeperStatsWindowSliderSettingLabel:SetFont(look)
    ShopkeeperStatsWindowSliderLabel:SetFont(look)

    for i = 1, #Shopkeeper.DataRows do
      local dataRow = Shopkeeper.DataRows[i]
      dataRow:GetNamedChild("Buyer"):SetFont(look)
      dataRow:GetNamedChild("Guild"):SetFont(look)
      dataRow:GetNamedChild("ItemName"):SetFont(look)
      dataRow:GetNamedChild("Quantity"):SetFont(look)
      dataRow:GetNamedChild("SellTime"):SetFont(look)
      dataRow:GetNamedChild("Price"):SetFont(look)
      if i <= #Shopkeeper.MiniDataRows then
        local miniDataRow = Shopkeeper.MiniDataRows[i]
        miniDataRow:GetNamedChild("Guild"):SetFont(miniLook)
        miniDataRow:GetNamedChild("ItemName"):SetFont(miniLook)
        miniDataRow:GetNamedChild("Quantity"):SetFont(miniLook)
        miniDataRow:GetNamedChild("SellTime"):SetFont(miniLook)
        miniDataRow:GetNamedChild("Price"):SetFont(miniLook)
      end
    end
  end
end

-- Item tooltips
function Shopkeeper:ShowToolTip(itemName, itemButton)
  InitializeTooltip(ItemTooltip, itemButton)
  ItemTooltip:SetLink(itemName)
end

-- Clear a given row's data
function Shopkeeper.ClearDataRow(index)
  if index < 1 or index > 15 then
    return
  end

  local dataRow = Shopkeeper.DataRows[index]
  dataRow:GetNamedChild("Buyer"):SetText("")
  dataRow:GetNamedChild("Buyer"):SetHandler("OnMouseDoubleClick", nil)
  dataRow:GetNamedChild("Guild"):SetText("")
  dataRow:GetNamedChild("ItemIcon"):SetTexture(nil)
  dataRow:GetNamedChild("ItemIcon"):SetHidden(true)
  local itemCell = dataRow:GetNamedChild("ItemName")
  itemCell:SetText("")
  itemCell:SetHandler("OnMouseDoubleClick", nil)
  itemCell:SetHandler("OnMouseEnter", nil)
  dataRow:GetNamedChild("Quantity"):SetText("")
  dataRow:GetNamedChild("SellTime"):SetText("")
  dataRow:GetNamedChild("Price"):SetText("")
end

function Shopkeeper.ClearMiniDataRow(index)
  if index < 1 or index > 8 then
    return
  end

  local dataRow = Shopkeeper.MiniDataRows[index]
  dataRow:GetNamedChild("Guild"):SetText("")
  dataRow:GetNamedChild("ItemIcon"):SetTexture(nil)
  dataRow:GetNamedChild("ItemIcon"):SetHidden(true)
  local itemCell = dataRow:GetNamedChild("ItemName")
  itemCell:SetText("")
  itemCell:SetHandler("OnMouseDoubleClick", nil)
  itemCell:SetHandler("OnMouseEnter", nil)
  dataRow:GetNamedChild("Quantity"):SetText("")
  dataRow:GetNamedChild("SellTime"):SetText("")
  dataRow:GetNamedChild("Price"):SetText("")
end

-- Fill out a row with the given data
function Shopkeeper.SetDataRow(index, buyer, guild, itemName, icon, quantity, sellTime, price, seller)
  if index < 1 or index > 15 then return end

  local dataRow = Shopkeeper.DataRows[index]

  -- Some extra stuff for the Buyer cell to handle double-click and color changes
  local buyerCell = dataRow:GetNamedChild("Buyer")
  buyerCell:SetText(buyer)
  -- If the seller is the player, color the buyer green.  Otherwise, blue.
  local acctName = Shopkeeper.GetAccountName()
  if seller == acctName then
    buyerCell:SetNormalFontColor(0.18, 0.77, 0.05, 1)
    buyerCell:SetPressedFontColor(0.18, 0.77, 0.05, 1)
    buyerCell:SetMouseOverFontColor(0.32, 0.90, 0.18, 1)
  else
    buyerCell:SetNormalFontColor(0.21, 0.54, 0.94, 1)
    buyerCell:SetPressedFontColor(0.21, 0.54, 0.94, 1)
    buyerCell:SetMouseOverFontColor(0.34, 0.67, 1, 1)
  end
  buyerCell:SetHandler("OnMouseDoubleClick", function()
    if SCENE_MANAGER.currentScene.name == "mailSend" then
      ZO_MailSendToField:SetText("")
      ZO_MailSendToField:SetText(ZO_MailSendToField:GetText() .. buyer)
    else
      ZO_ChatWindowTextEntryEditBox:SetText("/w " .. buyer .. " " .. ZO_ChatWindowTextEntryEditBox:GetText())
    end
  end)
  local buyerCellLabel = buyerCell:GetLabelControl()
  buyerCellLabel:SetWrapMode(TEXT_WRAP_MODE_ELLIPSIS)

  -- Guild cell
  dataRow:GetNamedChild("Guild"):SetText(guild)
  local guildCellLabel = dataRow:GetNamedChild("Guild"):GetLabelControl()
  guildCellLabel:SetWrapMode(TEXT_WRAP_MODE_ELLIPSIS)

  -- Item Icon
  dataRow:GetNamedChild("ItemIcon"):SetHidden(false)
  dataRow:GetNamedChild("ItemIcon"):SetTexture(icon)

  -- Item name cell
  local itemCell = dataRow:GetNamedChild("ItemName")
  itemCell:SetText(zo_strformat("<<t:1>>", itemName))
  -- Insert the item link into the chat box, with a quick substitution so brackets show up
  itemCell:SetHandler("OnMouseDoubleClick", function()
    ZO_ChatWindowTextEntryEditBox:SetText(ZO_ChatWindowTextEntryEditBox:GetText() .. string.gsub(itemName, "|H0", "|H1"))
  end)
  itemCell:SetHandler("OnMouseEnter", function() Shopkeeper:ShowToolTip(itemName, itemCell) end)
  itemCell:SetHandler("OnMouseExit", function() ClearTooltip(ItemTooltip) end)
  local itemCellLabel = itemCell:GetLabelControl()
  itemCellLabel:SetWrapMode(TEXT_WRAP_MODE_ELLIPSIS)

  -- Quantity cell
  dataRow:GetNamedChild("Quantity"):SetText("x" .. quantity)

  -- Sale time cell
  dataRow:GetNamedChild("SellTime"):SetText(sellTime)

  -- Handle the setting of whether or not to show pre-cut sale prices
  -- math.floor(number + 0.5) is a quick shorthand way to round for
  -- positive values.
  local dispPrice = price
  if Shopkeeper.savedVariables.showFullPrice then
    if Shopkeeper.savedVariables.showUnitPrice and quantity > 0 then
      dispPrice = math.floor((dispPrice / quantity) + 0.5)
    end
  else
    local cutPrice = price * (1 - (GetTradingHouseCutPercentage() / 100))
    if Shopkeeper.savedVariables.showUnitPrice and quantity > 0 then
      cutPrice = cutPrice / quantity
    end
    dispPrice = math.floor(cutPrice + 0.5)
  end

  -- Insert thousands separators for the price
  local stringPrice = Shopkeeper.localizedNumber(dispPrice)

  -- Finally, set the price
  dataRow:GetNamedChild("Price"):SetText(stringPrice .. " " .. string.format("|t16:16:%s|t","EsoUI/Art/currency/currency_gold.dds"))
end

-- Fill out a row with the given data
function Shopkeeper.SetMiniDataRow(index, guild, itemName, icon, quantity, sellTime, price, seller)
  if index < 1 or index > 8 then return end

  local dataRow = Shopkeeper.MiniDataRows[index]

  -- Guild cell
  dataRow:GetNamedChild("Guild"):SetText(guild)
  local guildCellLabel = dataRow:GetNamedChild("Guild"):GetLabelControl()
  guildCellLabel:SetWrapMode(TEXT_WRAP_MODE_ELLIPSIS)

  -- Item Icon
  dataRow:GetNamedChild("ItemIcon"):SetHidden(false)
  dataRow:GetNamedChild("ItemIcon"):SetTexture(icon)

  -- Item name cell
  local itemCell = dataRow:GetNamedChild("ItemName")
  itemCell:SetText(zo_strformat("<<t:1>>", itemName))
  -- Insert the item link into the chat box, with a quick substitution so brackets show up
  itemCell:SetHandler("OnMouseDoubleClick", function()
    ZO_ChatWindowTextEntryEditBox:SetText(ZO_ChatWindowTextEntryEditBox:GetText() .. string.gsub(itemName, "|H0", "|H1"))
  end)
  itemCell:SetHandler("OnMouseEnter", function() Shopkeeper:ShowToolTip(itemName, itemCell) end)
  itemCell:SetHandler("OnMouseExit", function() ClearTooltip(ItemTooltip) end)
  local itemCellLabel = itemCell:GetLabelControl()
  itemCellLabel:SetWrapMode(TEXT_WRAP_MODE_ELLIPSIS)

  -- Quantity cell
  dataRow:GetNamedChild("Quantity"):SetText("x" .. quantity)

  -- Sale time cell
  dataRow:GetNamedChild("SellTime"):SetText(sellTime)

  -- Handle the setting of whether or not to show pre-cut sale prices
  -- math.floor(number + 0.5) is a quick shorthand way to round for
  -- positive values.
  local dispPrice = price
  if Shopkeeper.savedVariables.showFullPrice then
    if Shopkeeper.savedVariables.showUnitPrice and quantity > 0 then
      dispPrice = math.floor((dispPrice / quantity) + 0.5)
    end
  else
    local cutPrice = price * (1 - (GetTradingHouseCutPercentage() / 100))
    if Shopkeeper.savedVariables.showUnitPrice and quantity > 0 then
      cutPrice = cutPrice / quantity
    end
    dispPrice = math.floor(cutPrice + 0.5)
  end

  -- Insert thousands separators for the price
  local stringPrice = Shopkeeper.localizedNumber(dispPrice)

  -- Finally, set the price
  dataRow:GetNamedChild("Price"):SetText(stringPrice .. " " .. string.format("|t16:16:%s|t","EsoUI/Art/currency/currency_gold.dds"))
end

-- Build the data rows based on the position of the slider
function Shopkeeper.DisplayRows()
  if Shopkeeper.savedVariables.viewSize == "full" then
    local startIndex = Shopkeeper.shopSlider:GetValue()
    if startIndex + #Shopkeeper.DataRows > #Shopkeeper.SearchTable then
      startIndex = #Shopkeeper.SearchTable - #Shopkeeper.DataRows
    end

    if startIndex < 1 then startIndex = 0 end

    -- Hide the slider if there's less than a full page of results
    Shopkeeper.shopSlider:SetHidden(#Shopkeeper.SearchTable < 16)

    for i = 1, #Shopkeeper.DataRows do
      local rowIndex = i + startIndex
      if rowIndex > #Shopkeeper.SearchTable then
        Shopkeeper.ClearDataRow(i)
        Shopkeeper.shopSlider:SetHidden(true)
      else
        local scanResult = Shopkeeper.SearchTable[rowIndex]

        Shopkeeper.SetDataRow(i, scanResult[1], scanResult[2], scanResult[3], scanResult[4], scanResult[5], Shopkeeper.textTimeSince(scanResult[6], false), scanResult[7], scanResult[8])
      end
    end

    -- Scale the slider's range to the number of items we have minus the number of rows
    local sliderMax = 0
    local tableToUse = Shopkeeper.ScanResults
    if Shopkeeper.viewMode == "self" then tableToUse = Shopkeeper.SelfSales end
    if #tableToUse > 15 then sliderMax = (#tableToUse - 15) end
    Shopkeeper.shopSlider:SetMinMax(0, sliderMax)
  else
    local startIndex = Shopkeeper.miniShopSlider:GetValue()
    if startIndex + #Shopkeeper.MiniDataRows > #Shopkeeper.SearchTable then
      startIndex = #Shopkeeper.SearchTable - #Shopkeeper.MiniDataRows
    end

    if startIndex < 1 then startIndex = 0 end

    -- Hide the slider if there's less than a full page of results
    Shopkeeper.miniShopSlider:SetHidden(#Shopkeeper.SearchTable < 9)

    for i = 1, #Shopkeeper.MiniDataRows do
      local rowIndex = i + startIndex
      if rowIndex > #Shopkeeper.SearchTable then
        Shopkeeper.ClearMiniDataRow(i)
        Shopkeeper.miniShopSlider:SetHidden(true)
      else
        local scanResult = Shopkeeper.SearchTable[rowIndex]

        Shopkeeper.SetMiniDataRow(i, scanResult[2], scanResult[3], scanResult[4], scanResult[5], Shopkeeper.textTimeSince(scanResult[6], false), scanResult[7], scanResult[8])
      end
    end

    -- Scale the slider's range to the number of items we have minus the number of rows
    local sliderMax = 0
    local tableToUse = Shopkeeper.ScanResults
    if Shopkeeper.viewMode == "self" then tableToUse = Shopkeeper.SelfSales end
    if #tableToUse > 8 then sliderMax = (#tableToUse - 8) end
    Shopkeeper.miniShopSlider:SetMinMax(0, sliderMax)
  end
end

function Shopkeeper.ToggleViewMode()
  if Shopkeeper.savedVariables.viewSize == "full" then
    Shopkeeper.savedVariables.viewSize = "half"
    ShopkeeperWindow:SetHidden(true)
    Shopkeeper.DisplayRows()
    ShopkeeperMiniWindow:SetHidden(false)

    if Shopkeeper.savedVariables.openWithMail then
      MAIL_INBOX_SCENE:RemoveFragment(Shopkeeper.uiFragment)
      MAIL_SEND_SCENE:RemoveFragment(Shopkeeper.uiFragment)
      MAIL_INBOX_SCENE:AddFragment(Shopkeeper.miniUiFragment)
      MAIL_SEND_SCENE:AddFragment(Shopkeeper.miniUiFragment)
    end

    if Shopkeeper.savedVariables.openWithStore then
      TRADING_HOUSE_SCENE:RemoveFragment(Shopkeeper.uiFragment)
      TRADING_HOUSE_SCENE:AddFragment(Shopkeeper.miniUiFragment)
    end
  else
    Shopkeeper.savedVariables.viewSize = "full"
    ShopkeeperMiniWindow:SetHidden(true)
    Shopkeeper.DisplayRows()
    ShopkeeperWindow:SetHidden(false)

    if Shopkeeper.savedVariables.openWithMail then
      MAIL_INBOX_SCENE:RemoveFragment(Shopkeeper.miniUiFragment)
      MAIL_SEND_SCENE:RemoveFragment(Shopkeeper.miniUiFragment)
      MAIL_INBOX_SCENE:AddFragment(Shopkeeper.uiFragment)
      MAIL_SEND_SCENE:AddFragment(Shopkeeper.uiFragment)
    end

    if Shopkeeper.savedVariables.openWithStore then
      TRADING_HOUSE_SCENE:RemoveFragment(Shopkeeper.miniUiFragment)
      TRADING_HOUSE_SCENE:AddFragment(Shopkeeper.uiFragment)
    end
  end
end

-- Set the visibility status of the main window to the opposite of its current status
function Shopkeeper.ToggleShopkeeperWindow()
  if Shopkeeper.savedVariables.viewSize == "full" then
    ShopkeeperMiniWindow:SetHidden(true)
    if ShopkeeperWindow:IsHidden() then
      Shopkeeper.DisplayRows()
      SetGameCameraUIMode(true)
    end

    ShopkeeperWindow:SetHidden(not ShopkeeperWindow:IsHidden())
  else
    ShopkeeperWindow:SetHidden(true)
    if ShopkeeperMiniWindow:IsHidden() then
      Shopkeeper.DisplayRows()
      SetGameCameraUIMode(true)
    end

    ShopkeeperMiniWindow:SetHidden(not ShopkeeperMiniWindow:IsHidden())
  end
end

-- Set the visibility status of the stats window to the opposite of its current status
function Shopkeeper.ToggleShopkeeperStatsWindow()
  if ShopkeeperStatsWindow:IsHidden() then Shopkeeper.UpdateStatsWindow() end
  ShopkeeperStatsWindow:SetHidden(not ShopkeeperStatsWindow:IsHidden())
end

-- Sort the scan results by 'ordering' order (asc/desc).
-- We sort both the search result table and the master scan results table because either we do it
-- now or we sort at separate times and try to keep track of what state each is in.  No thanks!
function Shopkeeper.SortByPrice(ordering)
  Shopkeeper.curSort[1] = "price"
  Shopkeeper.curSort[2] = ordering
  if ordering == "asc" then
    -- If they're viewing prices per-unit, then we need to sort on price / quantity.
    if Shopkeeper.savedVariables.showUnitPrice then
      table.sort(Shopkeeper.SearchTable, function(sortA, sortB)
        -- In case quantity ends up 0 or nil somehow, let's not divide by it
        if sortA[5] and sortA[5] > 0 and sortB[5] and sortB[5] > 0 then
          return (sortA[7] / sortA[5]) > (sortB[7] / sortB[5])
        else
          return sortA[7] > sortB[7]
        end
      end)
      table.sort(Shopkeeper.ScanResults, function(sortA, sortB)
        if sortA[5] and sortA[5] > 0 and sortB[5] and sortB[5] > 0 then
          return (sortA[7] / sortA[5]) > (sortB[7] / sortB[5])
        else
          return sortA[7] > sortB[7]
        end
      end)
      table.sort(Shopkeeper.SelfSales, function(sortA, sortB)
        if sortA[5] and sortA[5] > 0 and sortB[5] and sortB[5] > 0 then
          return (sortA[7] / sortA[5]) > (sortB[7] / sortB[5])
        else
          return sortA[7] > sortB[7]
        end
      end)
    -- Otherwise just sort on pure price.
    else
      table.sort(Shopkeeper.SearchTable, function(sortA, sortB)
        return sortA[7] > sortB[7]
      end)
      table.sort(Shopkeeper.ScanResults, function(sortA, sortB)
        return sortA[7] > sortB[7]
      end)
      table.sort(Shopkeeper.SelfSales, function(sortA, sortB)
        return sortA[7] > sortB[7]
      end)
    end
    ShopkeeperWindowSortPrice:SetTexture("/esoui/art/miscellaneous/list_sortheader_icon_sortup.dds")
  else
    -- And the same thing with descending sort
    if Shopkeeper.savedVariables.showUnitPrice then
      table.sort(Shopkeeper.SearchTable, function(sortA, sortB)
        return (sortA[7] / sortA[5]) < (sortB[7] / sortB[5])
      end)
      table.sort(Shopkeeper.ScanResults, function(sortA, sortB)
        return (sortA[7] / sortA[5]) < (sortB[7] / sortB[5])
      end)
      table.sort(Shopkeeper.SelfSales, function(sortA, sortB)
        return (sortA[7] / sortA[5]) < (sortB[7] / sortB[5])
      end)
    else
      table.sort(Shopkeeper.SearchTable, function(sortA, sortB)
        return sortA[7] < sortB[7]
      end)
      table.sort(Shopkeeper.ScanResults, function(sortA, sortB)
        return sortA[7] < sortB[7]
      end)
      table.sort(Shopkeeper.SelfSales, function(sortA, sortB)
        return sortA[7] < sortB[7]
      end)
    end
    ShopkeeperWindowSortPrice:SetTexture("/esoui/art/miscellaneous/list_sortheader_icon_sortdown.dds")
  end

  ShopkeeperWindowSortTime:SetTexture("/esoui/art/miscellaneous/list_sortheader_icon_neutral.dds")

  Shopkeeper.DisplayRows()
end

-- Sort the scan results by 'ordering' order (asc/desc).
-- We sort both the search result table and the master scan results table because either we do it
-- now or we sort at separate times and try to keep track of what state each is in.  No thanks!
function Shopkeeper.SortByTime(ordering)
  Shopkeeper.curSort[1] = "time"
  Shopkeeper.curSort[2] = ordering
  if ordering == "asc" then
    table.sort(Shopkeeper.SearchTable, function(sortA, sortB)
      return sortA[6] < sortB[6]
    end)
    table.sort(Shopkeeper.ScanResults, function(sortA, sortB)
      return sortA[6] < sortB[6]
    end)
    table.sort(Shopkeeper.SelfSales, function(sortA, sortB)
      return sortA[6] < sortB[6]
    end)
    ShopkeeperWindowSortTime:SetTexture("/esoui/art/miscellaneous/list_sortheader_icon_sortup.dds")
  else
    table.sort(Shopkeeper.SearchTable, function(sortA, sortB)
      return sortA[6] > sortB[6]
    end)
    table.sort(Shopkeeper.ScanResults, function(sortA, sortB)
      return sortA[6] > sortB[6]
    end)
    table.sort(Shopkeeper.SelfSales, function(sortA, sortB)
      return sortA[6] > sortB[6]
    end)
    ShopkeeperWindowSortTime:SetTexture("/esoui/art/miscellaneous/list_sortheader_icon_sortdown.dds")
  end

  ShopkeeperWindowSortPrice:SetTexture("/esoui/art/miscellaneous/list_sortheader_icon_neutral.dds")

  Shopkeeper.DisplayRows()
end

-- A convenience function to switch betwteen ascending and descending sort
-- as well as between price and time sorting if the other is currently active
function Shopkeeper.PriceSort()
  if Shopkeeper.curSort[1] == "price" and Shopkeeper.curSort[2] == "desc" then
    Shopkeeper.SortByPrice("asc")
  else
    Shopkeeper.SortByPrice("desc")
  end
end

-- A convenience function to switch betwteen ascending and descending sort
-- as well as between price and time sorting if the other is currently active
function Shopkeeper.TimeSort()
  if Shopkeeper.curSort[1] == "time" and Shopkeeper.curSort[2] == "desc" then
    Shopkeeper.SortByTime("asc")
  else
    Shopkeeper.SortByTime("desc")
  end
end

function Shopkeeper.SalesStats(statsDays)
  local itemsSold = 0
  local goldMade = 0
  local largestSingle = {0, nil}
  local oldestTime = 0
  local newestTime = 0
  -- 86,400 seconds in a day; this will be the epoch time statsDays ago
  local statsDaysEpoch = GetTimeStamp() - (86400 * statsDays)

  -- Loop through the player's sales and create the stats as appropriate
  -- (everything or everything with a timestamp after statsDaysEpoch)
  for i = 1, #Shopkeeper.SelfSales do
    local theItem = Shopkeeper.SelfSales[i]
    if statsDays == 0 or theItem[6] > statsDaysEpoch then
      itemsSold = itemsSold + 1
      goldMade = goldMade + theItem[7]
      if oldestTime == 0 or theItem[6] < oldestTime then oldestTime = theItem[6] end
      if newestTime == 0 or theItem[6] > newestTime then newestTime = theItem[6] end
      if theItem[7] > largestSingle[1] then largestSingle = {theItem[7], theItem[3]} end
     end
  end

  -- Newest timestamp seen minus oldest timestamp seen is the number of seconds between
  -- them; divided by 86,400 it's the number of days (or at least close enough for this)
  local timeWindow = newestTime - oldestTime
  local dayWindow = 1
  if timeWindow > 86400 then dayWindow = math.floor(timeWindow / 86400) + 1 end
  local goldPerDay = goldMade / dayWindow

  -- If they have the option set to show prices post-cut, calculate that here
  if not Shopkeeper.savedVariables.showFullPrice then
    local cutMult = 1 - (GetTradingHouseCutPercentage() / 100)
    goldMade = math.floor(goldMade * cutMult + 0.5)
    goldPerDay = math.floor(goldPerDay * cutMult + 0.5)
    largestSingle[1] = math.floor(largestSingle[1] * cutMult + 0.5)
  end

  -- Return the statistical data in a convenient table
  return { numSold = itemsSold,
           numDays = dayWindow,
           totalGold = goldMade,
           avgGold = goldPerDay,
           biggestSale = largestSingle }
end

function Shopkeeper.UpdateStatsWindow()
  local sliderLevel = Shopkeeper.statsSlider:GetValue()
  if sliderLevel == 0 then
    ShopkeeperStatsWindowSliderSettingLabel:SetText(Shopkeeper.translate('statsTimeAll'))
  else
    ShopkeeperStatsWindowSliderSettingLabel:SetText(string.format(Shopkeeper.translate('statsTimeSome'), sliderLevel))
  end

  local newStats = Shopkeeper.SalesStats(sliderLevel)
  ShopkeeperStatsWindowItemsSoldLabel:SetText(string.format(Shopkeeper.translate('statsItemsSold'), Shopkeeper.localizedNumber(newStats['numSold'])))
  ShopkeeperStatsWindowTotalGoldLabel:SetText(string.format(Shopkeeper.translate('statsTotalGold'), Shopkeeper.localizedNumber(newStats['totalGold']), Shopkeeper.localizedNumber(newStats['avgGold'])))
  ShopkeeperStatsWindowBiggestSaleLabel:SetText(string.format(Shopkeeper.translate('statsBiggest'), zo_strformat("<<t:1>>", newStats['biggestSale'][2]), Shopkeeper.localizedNumber(newStats['biggestSale'][1])))
end

-- LibAddon init code
function Shopkeeper:LibAddonInit()
  local LAM = LibStub("LibAddonMenu-2.0")
  if LAM then
    local LMP = LibStub("LibMediaProvider-1.0")
    if LMP then
      local panelData = {
        type = "panel",
        name = "Shopkeeper",
        displayName = "Shopkeeper",
        author = "Khaibit",
        version = Shopkeeper.version,
        registerForDefaults = true,
      }
      LAM:RegisterAddonPanel("ShopkeeperOptions", panelData)

      local optionsData = {
        [1] = {
          type = "submenu",
          name = Shopkeeper.translate('alertOptionsName'),
          tooltip = Shopkeeper.translate('alertOptionsTip'),
          controls = {
            [1] = {
              type = "checkbox",
              name = Shopkeeper.translate('saleAlertAnnounceName'),
              tooltip = Shopkeeper.translate('saleAlertAnnounceTip'),
              getFunc = function() return Shopkeeper.savedVariables.showAnnounceAlerts end,
              setFunc = function(value) Shopkeeper.savedVariables.showAnnounceAlerts = value end,
            },
            [2] = {
              type = "checkbox",
              name = Shopkeeper.translate('saleAlertChatName'),
              tooltip = Shopkeeper.translate('saleAlertChatTip'),
              getFunc = function() return Shopkeeper.savedVariables.showChatAlerts end,
              setFunc = function(value) Shopkeeper.savedVariables.showChatAlerts = value end,
            },
            [3] = {
              type = "dropdown",
              name = Shopkeeper.translate('alertTypeName'),
              tooltip = Shopkeeper.translate('alertTypeTip'),
              choices = Shopkeeper.soundKeys(),
              getFunc = function() return Shopkeeper.searchSounds(Shopkeeper.savedVariables.alertSoundName) end,
              setFunc = function(value)
                Shopkeeper.savedVariables.alertSoundName = Shopkeeper.searchSoundNames(value)
                PlaySound(Shopkeeper.savedVariables.alertSoundName)
              end,
            },
            [4] = {
              type = "checkbox",
              name = Shopkeeper.translate('multAlertName'),
              tooltip = Shopkeeper.translate('multAlertTip'),
              getFunc = function() return Shopkeeper.savedVariables.showMultiple end,
              setFunc = function(value) Shopkeeper.savedVariables.showMultiple = value end,
            },
          },
        },
        [2] = {
          type = "checkbox",
          name = Shopkeeper.translate('openMailName'),
          tooltip = Shopkeeper.translate('openMailTip'),
          getFunc = function() return Shopkeeper.savedVariables.openWithMail end,
          setFunc = function(value)
            Shopkeeper.savedVariables.openWithMail = value
            if value then
              -- Register for the mail scenes
              MAIL_INBOX_SCENE:AddFragment(Shopkeeper.uiFragment)
              MAIL_SEND_SCENE:AddFragment(Shopkeeper.uiFragment)
            else
              -- Unregister for the mail scenes
              MAIL_INBOX_SCENE:RemoveFragment(Shopkeeper.uiFragment)
              MAIL_SEND_SCENE:RemoveFragment(Shopkeeper.uiFragment)
            end
          end,
        },
        [3] = {
          type = "checkbox",
          name = Shopkeeper.translate('openStoreName'),
          tooltip = Shopkeeper.translate('openStoreTip'),
          getFunc = function() return Shopkeeper.savedVariables.openWithStore end,
          setFunc = function(value)
            Shopkeeper.savedVariables.openWithStore = value
            if value then
              -- Register for the store scene
              TRADING_HOUSE_SCENE:AddFragment(Shopkeeper.uiFragment)
            else
              -- Unregister for the store scene
              TRADING_HOUSE_SCENE:RemoveFragment(Shopkeeper.uiFragment)
            end
          end,
        },
        [4] = {
          type = "checkbox",
          name = Shopkeeper.translate('fullSaleName'),
          tooltip = Shopkeeper.translate('fullSaleTip'),
          getFunc = function() return Shopkeeper.savedVariables.showFullPrice end,
          setFunc = function(value)
            Shopkeeper.savedVariables.showFullPrice = value
            Shopkeeper.DisplayRows()
          end,
        },
        [5] = {
          type = "slider",
          name = Shopkeeper.translate('scanFreqName'),
          tooltip = Shopkeeper.translate('scanFreqTip'),
          min = 60,
          max = 600,
          getFunc = function() return Shopkeeper.savedVariables.scanFreq end,
          setFunc = function(value)
            Shopkeeper.savedVariables.scanFreq = value
            EVENT_MANAGER:UnregisterForUpdate(Shopkeeper.name)
            local scanInterval = value * 1000
            EVENT_MANAGER:RegisterForUpdate(Shopkeeper.name, scanInterval, function() Shopkeeper:ScanStores(false, false) end)
          end,
        },
        [6] = {
          type = "slider",
          name = Shopkeeper.translate('historyDepthName'),
          tooltip = Shopkeeper.translate('historyDepthTip'),
          min = 500,
          max = 7500,
          getFunc = function() return Shopkeeper.savedVariables.historyDepth end,
          setFunc = function(value) Shopkeeper.savedVariables.historyDepth = value end,
        },
        [7] = {
          type = "dropdown",
          name = Shopkeeper.translate('windowFontName'),
          tooltip = Shopkeeper.translate('windowFontTip'),
          choices = LMP:List(LMP.MediaType.FONT),
          getFunc = function() return Shopkeeper.savedVariables.windowFont end,
          setFunc = function(value)
            Shopkeeper.savedVariables.windowFont = value
            Shopkeeper.UpdateFonts()
          end,
        },
      }
      LAM:RegisterOptionControls("ShopkeeperOptions", optionsData)
    end
  end
end

-- Handle scrolling the main window
function Shopkeeper.OnSliderMouseWheel(self, delta)
  if Shopkeeper.savedVariables.viewSize == "full" then
    local oldSliderLevel = Shopkeeper.shopSlider:GetValue()
    local newSliderLevel = oldSliderLevel - delta
    Shopkeeper.shopSlider:SetValue(newSliderLevel)
  else
    local oldSliderLevel = Shopkeeper.miniShopSlider:GetValue()
    local newSliderLevel = oldSliderLevel - delta
    Shopkeeper.miniShopSlider:SetValue(newSliderLevel)
  end
end

-- Update the table if the slider moved
function Shopkeeper.OnSliderMoved(self, sliderLevel, eventReason)
  Shopkeeper.DisplayRows()
end

function Shopkeeper.OnStatsSliderMoved(self, sliderLevel, eventReason)
  Shopkeeper.UpdateStatsWindow()
end

-- Filters the ScanResults (or SelfSales) table into the SearchTable,
-- using the Shopkeeper.viewMode to determine which one to use.
function Shopkeeper.DoSearch(searchText)
  Shopkeeper.SearchTable = {}
  local searchTerm = string.lower(searchText)
  local acctName = Shopkeeper.GetAccountName()
  local tableToUse = Shopkeeper.ScanResults
  if Shopkeeper.viewMode == "self" then tableToUse = Shopkeeper.SelfSales end

  -- Actually carry out the search
  for j = 1, #tableToUse do
    local result = tableToUse[j]
    if result then
      if searchText == nil or searchText == " " then
        table.insert(Shopkeeper.SearchTable, result)
      else
        for i = 1, 3 do
          local fixedTerm = result[i]
          if i == 3 then
            fixedTerm = GetItemLinkName(fixedTerm)
          end

          if string.lower(fixedTerm):find(searchTerm) then
            if result[i] ~= nil then
              table.insert(Shopkeeper.SearchTable, result)
              break
            end
          end
        end
      end
    end
  end

  Shopkeeper.DisplayRows()
end

-- Switch between all sales and your sales
function Shopkeeper.SwitchViewMode()
  if Shopkeeper.viewMode == "self" then
    ShopkeeperSwitchViewButton:SetText(Shopkeeper.translate('viewModeYourName'))
    ShopkeeperWindowTitle:SetText("Shopkeeper - " .. Shopkeeper.translate('allSalesTitle'))
    ShopkeeperMiniSwitchViewButton:SetText(Shopkeeper.translate('viewModeYourName'))
    ShopkeeperMiniWindowTitle:SetText("Shopkeeper - " .. Shopkeeper.translate('allSalesTitle'))
    Shopkeeper.viewMode = "all"
  else
    ShopkeeperSwitchViewButton:SetText(Shopkeeper.translate('viewModeAllName'))
    ShopkeeperWindowTitle:SetText("Shopkeeper - " .. Shopkeeper.translate('yourSalesTitle'))
    ShopkeeperMiniSwitchViewButton:SetText(Shopkeeper.translate('viewModeAllName'))
    ShopkeeperMiniWindowTitle:SetText("Shopkeeper - " .. Shopkeeper.translate('yourSalesTitle'))
    Shopkeeper.viewMode = "self"
  end

  Shopkeeper.DoSearch(ShopkeeperWindowSearchBox:GetText())
end

function Shopkeeper.SwitchPriceMode()
  if Shopkeeper.savedVariables.showUnitPrice then
    Shopkeeper.savedVariables.showUnitPrice = false
    ShopkeeperPriceSwitchButton:SetText(Shopkeeper.translate('showUnitPrice'))
    ShopkeeperWindowPrice:SetText(Shopkeeper.translate('priceColumnName'))
    ShopkeeperMiniPriceSwitchButton:SetText(Shopkeeper.translate('showUnitPrice'))
    ShopkeeperMiniWindowPrice:SetText(Shopkeeper.translate('priceColumnName'))
  else
    Shopkeeper.savedVariables.showUnitPrice = true
    ShopkeeperPriceSwitchButton:SetText(Shopkeeper.translate('showTotalPrice'))
    ShopkeeperWindowPrice:SetText(Shopkeeper.translate('priceEachColumnName'))
    ShopkeeperMiniPriceSwitchButton:SetText(Shopkeeper.translate('showTotalPrice'))
    ShopkeeperMiniWindowPrice:SetText(Shopkeeper.translate('priceEachColumnName'))
  end

  if Shopkeeper.curSort[1] == "price" then
    Shopkeeper.SortByPrice(Shopkeeper.curSort[2])
  else
    Shopkeeper.DisplayRows()
  end

end

-- Actually carries out of the scan of a specific guild store's sales history.
-- If checkOlder is true, will request older events first if there are any.
-- Inserts all events that occurred after guildID guild's last scan into the ScanResults table.
-- Inserts all events that occurred after guildID guild's last scan and were sold by the player
-- into the SelfSales table.
function Shopkeeper:DoScan(guildID, checkOlder)
  local numEvents = GetNumGuildEvents(guildID, GUILD_HISTORY_SALES)
  local thePlayer = string.lower(Shopkeeper.GetAccountName())
  local timeStamp = GetTimeStamp()
  for i = 0, numEvents do
    local theEvent = {}
    _, theEvent.secsSince, theEvent.seller, theEvent.buyer,
    theEvent.quant, theEvent.itemName, theEvent.salePrice = GetGuildEventInfo(guildID, GUILD_HISTORY_SALES, i)
    theEvent.guild = GetGuildName(guildID)
    -- Only worry about items sold since our last scan
    if theEvent.secsSince ~= nil then
      theEvent.saleTime = timeStamp - theEvent.secsSince

      if Shopkeeper.acctSavedVariables.lastScan[guildID] == nil or theEvent.saleTime > Shopkeeper.acctSavedVariables.lastScan[guildID] then
        if theEvent.itemName ~= nil and theEvent.seller ~= nil and theEvent.buyer ~= nil and theEvent.salePrice ~= nil then
          -- Grab the icon
          local itemIcon, _, _, _ = GetItemLinkInfo(theEvent.itemName)

          -- If the seller is the player and this isn't a deep scan (and thus the first upon login or reset),
          -- queue up an alert
          if not checkOlder and (Shopkeeper.savedVariables.showChatAlerts or Shopkeeper.savedVariables.showAnnounceAlerts)
             and string.lower(theEvent.seller) == thePlayer then
            table.insert(Shopkeeper.alertQueue, theEvent)
          end

          -- Insert the entry into the ScanResults table
          table.insert(Shopkeeper.ScanResults, {theEvent.buyer, theEvent.guild, theEvent.itemName, itemIcon,
                                                theEvent.quant, theEvent.saleTime, theEvent.salePrice,
                                                theEvent.seller})

          -- And then, if it's the player's sale, insert into that table
          if string.lower(theEvent.seller) == thePlayer then
            table.insert(Shopkeeper.SelfSales, {theEvent.buyer, theEvent.guild, theEvent.itemName, itemIcon,
                                                theEvent.quant, theEvent.saleTime, theEvent.salePrice,
                                                theEvent.seller})
          end
        end
      end
    end
  end

  Shopkeeper.acctSavedVariables.lastScan[guildID] = GetTimeStamp()
  if guildID < GetNumGuilds() then
    if checkOlder and DoesGuildHistoryCategoryHaveMoreEvents((guildID + 1), GUILD_HISTORY_SALES) then
      RequestGuildHistoryCategoryNewest((guildID + 1), GUILD_HISTORY_SALES)
      RequestGuildHistoryCategoryOlder((guildID + 1), GUILD_HISTORY_SALES)
    else
      RequestGuildHistoryCategoryNewest((guildID + 1), GUILD_HISTORY_SALES)
    end
  end
end

-- Called after store scans complete, updates the search table
-- and slider range, then sorts the fresh table.
-- Once this is done writes out to the saved variables scan history
-- and updates the displayed table, sending a message to chat if
-- the scan was initiated via the 'refresh' button.
function Shopkeeper:PostScan(doAlert)
  Shopkeeper.isScanning = false
  Shopkeeper.DoSearch(ShopkeeperWindowSearchBox:GetText())
  -- Scale the slider's range to the number of items we have minus the number of rows
  local sliderMax = 0
  local tableToUse = Shopkeeper.ScanResults
  if Shopkeeper.viewMode == "self" then tableToUse = Shopkeeper.SelfSales end
  if #tableToUse > 15 then sliderMax = (#tableToUse - 15) end
  Shopkeeper.shopSlider:SetMinMax(0, sliderMax)
  sliderMax = 0
  if #tableToUse > 8 then sliderMax = (#tableToUse - 8) end
  Shopkeeper.miniShopSlider:SetMinMax(0, sliderMax)

  if Shopkeeper.curSort[1] == "time" then
    Shopkeeper.SortByTime(Shopkeeper.curSort[2])
  else
    Shopkeeper.SortByPrice(Shopkeeper.curSort[2])
  end
  Shopkeeper.acctSavedVariables.scanHistory = Shopkeeper.ScanResults
  Shopkeeper.DisplayRows()
  if doAlert then CHAT_SYSTEM:AddMessage("[Shopkeeper] " .. Shopkeeper.translate('refreshDone')) end

  -- If there's anything in the alert queue, handle it.
  if #Shopkeeper.alertQueue > 0 then
    -- Play an alert chime once if there are any alerts in the queue
    -- On-screen alerts play their own sounds, so only do this if they only get chat alerts
    if Shopkeeper.savedVariables.showChatAlerts and not Shopkeeper.savedVariables.showAnnounceAlerts then
      PlaySound(Shopkeeper.savedVariables.alertSoundName)
    end

    local numSold = 0
    local totalGold = 0
    local numAlerts = #Shopkeeper.alertQueue
    for i = 1, numAlerts do
      local theEvent = table.remove(Shopkeeper.alertQueue, 1)
      numSold = numSold + 1

      -- Adjust the price if they want the post-cut prices instead
      local dispPrice = theEvent.salePrice
      if not Shopkeeper.savedVariables.showFullPrice then
        local cutPrice = price * (1 - (GetTradingHouseCutPercentage() / 100))
        dispPrice = math.floor(cutPrice + 0.5)
      end
      totalGold = totalGold + dispPrice

      -- If they want multiple alerts, we'll alert on each loop iteration
      -- or if there's only one.
      if Shopkeeper.savedVariables.showMultiple or numAlerts == 1 then
        -- Insert thousands separators for the price
        local stringPrice = Shopkeeper.localizedNumber(dispPrice)

        -- Only make a sound on the first one if sounds are turned on
        -- To avoid sound spam on multiple sales
        -- (Because of how and/or work in LUA, "X and Y or Z" is equivalent
        -- to the C-style ternary operator "X ? Y : Z" so long as Y is not
        -- false or nil.)
        local alertSound = (i > 1) and SOUNDS.NONE or Shopkeeper.savedVariables.alertSoundName

        -- On-screen alert
        if Shopkeeper.savedVariables.showAnnounceAlerts then
          -- German word order differs so argument order also needs to be changed
          -- Also due to plurality differences in German, need to differentiate
          -- single item sold vs. multiple of an item sold.
          if Shopkeeper.locale == "de" then
            if theEvent.quant > 1 then
              CENTER_SCREEN_ANNOUNCE:DisplayMessage(CSA_EVENT_SMALL_TEXT, alertSound,
                string.format(Shopkeeper.translate('salesAlertColor'), theEvent.quant, zo_strformat("<<t:1>>", theEvent.itemName),
                              stringPrice, theEvent.guild, Shopkeeper.textTimeSince(theEvent.saleTime, true)))
            else
              CENTER_SCREEN_ANNOUNCE:DisplayMessage(CSA_EVENT_SMALL_TEXT, alertSound,
                string.format(Shopkeeper.translate('salesAlertColorSingle'), zo_strformat("<<t:1>>", theEvent.itemName),
                              stringPrice, theEvent.guild, Shopkeeper.textTimeSince(theEvent.saleTime, true)))
            end
          else
            CENTER_SCREEN_ANNOUNCE:DisplayMessage(CSA_EVENT_SMALL_TEXT, alertSound,
              string.format(Shopkeeper.translate('salesAlertColor'), zo_strformat("<<t:1>>", theEvent.itemName),
                            theEvent.quant, stringPrice, theEvent.guild, Shopkeeper.textTimeSince(theEvent.saleTime, true)))
          end
        end

        -- Chat alert
        if Shopkeeper.savedVariables.showChatAlerts then
          if Shopkeeper.locale == "de" then
            if theEvent.quant > 1 then
              CHAT_SYSTEM:AddMessage(string.format("[Shopkeeper] " .. Shopkeeper.translate('salesAlert'),
                                     theEvent.quant, zo_strformat("<<t:1>>", theEvent.itemName), stringPrice, theEvent.guild, Shopkeeper.textTimeSince(theEvent.saleTime, true)))
            else
              CHAT_SYSTEM:AddMessage(string.format("[Shopkeeper] " .. Shopkeeper.translate('salesAlertSingle'),
                                     zo_strformat("<<t:1>>", theEvent.itemName), stringPrice, theEvent.guild, Shopkeeper.textTimeSince(theEvent.saleTime, true)))
            end
          else
            CHAT_SYSTEM:AddMessage(string.format("[Shopkeeper] " .. Shopkeeper.translate('salesAlert'),
                                   zo_strformat("<<t:1>>", theEvent.itemName), theEvent.quant, stringPrice, theEvent.guild, Shopkeeper.textTimeSince(theEvent.saleTime, true)))
          end
        end
      end
    end

    -- Otherwise, we'll just alert once with a summary at the end
    if not Shopkeeper.savedVariables.showMultiple and numAlerts > 1 then
      -- Insert thousands separators for the price
      local stringPrice = Shopkeeper.localizedNumber(totalGold)

      if Shopkeeper.savedVariables.showAnnounceAlerts then
        CENTER_SCREEN_ANNOUNCE:DisplayMessage(CSA_EVENT_SMALL_TEXT, Shopkeeper.savedVariables.alertSoundName,
          string.format(Shopkeeper.translate('salesGroupAlertColor'), numSold, stringPrice))
      else
        CHAT_SYSTEM:AddMessage(string.format("[Shopkeeper] " .. Shopkeeper.translate('salesGroupAlert'),
                               numSold, stringPrice))
      end
    end
  end

  -- Finally, update the table just in case
  Shopkeeper.DisplayRows()
end

-- Scans all stores a player has access to with 2-second delays between them.
-- Idea for spaced callbacks taken from awesomebilly's Luminary Trade/Sales
-- History addon.
function Shopkeeper:ScanStores(checkOlder, doAlert)
  -- If it's been less than a minute since we last scanned the store,
  -- don't do it again so we don't hammer the server either accidentally
  -- or on purpose
  local timeLimit = GetTimeStamp() - 60
  local guildNum = GetNumGuilds()
  if not Shopkeeper.isScanning and (Shopkeeper.acctSavedVariables.lastScan[guildNum] == nil or timeLimit > Shopkeeper.acctSavedVariables.lastScan[guildNum]) then
    -- Nothing to scan!
    if guildNum == 0 then return end

    Shopkeeper.isScanning = true
    if checkOlder and DoesGuildHistoryCategoryHaveMoreEvents(1, GUILD_HISTORY_SALES) then
      RequestGuildHistoryCategoryNewest(1, GUILD_HISTORY_SALES)
      RequestGuildHistoryCategoryOlder(1, GUILD_HISTORY_SALES)
    else
      RequestGuildHistoryCategoryNewest(1, GUILD_HISTORY_SALES)
    end

    for j = 1, guildNum do
      local guildID = GetGuildId(j)

      -- I need a better way to space out these checks than callbacks
      -- It works but feels ugly
      zo_callLater(function() Shopkeeper:DoScan(guildID, checkOlder) end, (((j - 1) * 2000) + 1000))
    end
    -- Once scans are done, wait a few seconds and do some cleanup
    zo_callLater(function() Shopkeeper:PostScan(doAlert) end, ((guildNum + 1) * 2000))
  end
end

-- It's silly, but I have to re-set the fonts each time the UI reloads because I define defaults
-- in the .xml (at least as far as I can figure.)  The PlayerActive event fires after each of these,
-- so it's a nice place to hook in for that.
function Shopkeeper.PlayerActive()
  Shopkeeper:UpdateFonts()
end

-- Handle the refresh button - do a scan if it's been more than a minute
-- since the last successful one.
function Shopkeeper.DoRefresh()
  local timeStamp = GetTimeStamp()

  -- If it's been less than a minute since we last scanned the store,
  -- don't do it again so we don't hammer the server either accidentally
  -- or on purpose
  local timeLimit = timeStamp - 59
  local guildNum = GetNumGuilds()
  if timeLimit > Shopkeeper.acctSavedVariables.lastScan[guildNum] then
    CHAT_SYSTEM:AddMessage("[Shopkeeper] " .. Shopkeeper.translate('refreshStart'))
    Shopkeeper:ScanStores(false, true)

  else
    CHAT_SYSTEM:AddMessage("[Shopkeeper] " .. Shopkeeper.translate('refreshWait'))
  end
end

-- Handle the reset button - clear out the search and scan tables,
-- and set the time of the last scan to -1.  The next interval scan
-- will grab the sales histories from each guild to re-populate.
function Shopkeeper.DoReset()
  Shopkeeper.ScanResults = {}
  Shopkeeper.SelfSales = {}
  Shopkeeper.SearchTable = {}
  Shopkeeper.acctSavedVariables.scanHistory = {}
  Shopkeeper.acctSavedVariables.lastScan = {}
  Shopkeeper.DisplayRows()
  Shopkeeper:ScanStores(true, false)
  CHAT_SYSTEM:AddMessage("[Shopkeeper] " .. Shopkeeper.translate('resetDone'))
end

-- Set up the main window and the additional button added to the guild store interface
function Shopkeeper:SetupShopkeeperWindow()
  -- Shopkeeper button in guild store screen
  local reopenShopkeeper = CreateControlFromVirtual("ShopkeeperReopenButton", ZO_TradingHouseLeftPane, "ZO_DefaultButton")
  reopenShopkeeper:SetAnchor(CENTER, ZO_TradingHouseLeftPane, BOTTOM, 0, 5)
  reopenShopkeeper:SetWidth(200)
  reopenShopkeeper:SetText("Shopkeeper")
  reopenShopkeeper:SetHandler("OnClicked", Shopkeeper.ToggleShopkeeperWindow)

  -- Shopkeeper button in mail screen
  local shopkeeperMail = CreateControlFromVirtual("ShopkeeperMailButton", ZO_MailInbox, "ZO_DefaultButton")
  shopkeeperMail:SetAnchor(TOPLEFT, ZO_MailInbox, TOPLEFT, 100, 4)
  shopkeeperMail:SetWidth(200)
  shopkeeperMail:SetText("Shopkeeper")
  shopkeeperMail:SetHandler("OnClicked", Shopkeeper.ToggleShopkeeperWindow)

  -- Set column headers and search label from translation
  ShopkeeperWindowBuyer:SetText(Shopkeeper.translate('buyerColumnName'))
  ShopkeeperWindowGuild:SetText(Shopkeeper.translate('guildColumnName'))
  ShopkeeperWindowItemName:SetText(Shopkeeper.translate('itemColumnName'))
  ShopkeeperWindowSellTime:SetText(Shopkeeper.translate('timeColumnName'))
  ShopkeeperMiniWindowGuild:SetText(Shopkeeper.translate('guildColumnName'))
  ShopkeeperMiniWindowItemName:SetText(Shopkeeper.translate('itemColumnName'))
  ShopkeeperMiniWindowSellTime:SetText(Shopkeeper.translate('timeColumnName'))

  if Shopkeeper.savedVariables.showUnitPrice then
    ShopkeeperWindowPrice:SetText(Shopkeeper.translate('priceEachColumnName'))
    ShopkeeperMiniWindowPrice:SetText(Shopkeeper.translate('priceEachColumnName'))
  else
    ShopkeeperWindowPrice:SetText(Shopkeeper.translate('priceColumnName'))
    ShopkeeperMiniWindowPrice:SetText(Shopkeeper.translate('priceColumnName'))
  end
  ShopkeeperWindowSearchLabel:SetText(Shopkeeper.translate('searchBoxName'))
  ShopkeeperMiniWindowSearchLabel:SetText(Shopkeeper.translate('searchBoxName'))

  -- Set second half of window title from translation
  ShopkeeperWindowTitle:SetText("Shopkeeper - " .. Shopkeeper.translate('yourSalesTitle'))
  ShopkeeperMiniWindowTitle:SetText("Shopkeeper - " .. Shopkeeper.translate('yourSalesTitle'))

  -- And set the stats window title and slider label from translation
  ShopkeeperStatsWindowTitle:SetText("Shopkeeper " .. Shopkeeper.translate('statsTitle'))
  ShopkeeperStatsWindowSliderLabel:SetText(Shopkeeper.translate('statsDays'))

  -- Set up some helpful tooltips for the Buyer and Item column headers
  ShopkeeperWindowBuyer:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('buyerTooltip'))
  end)

  ShopkeeperWindowBuyer:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)

  ShopkeeperWindowItemName:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('itemTooltip'))
  end)

  ShopkeeperWindowItemName:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)

  ShopkeeperMiniWindowItemName:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('itemTooltip'))
  end)

  ShopkeeperMiniWindowItemName:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)

  ShopkeeperWindowSellTime:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('sortTimeTip'))
  end)

  ShopkeeperWindowSellTime:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)

  ShopkeeperMiniWindowSellTime:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('sortTimeTip'))
  end)

  ShopkeeperMiniWindowSellTime:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)

  ShopkeeperWindowPrice:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('sortPriceTip'))
  end)

  ShopkeeperWindowPrice:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)

  ShopkeeperMiniWindowPrice:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('sortPriceTip'))
  end)

  ShopkeeperMiniWindowPrice:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)


  -- View switch button
  local switchViews = CreateControlFromVirtual("ShopkeeperSwitchViewButton", ShopkeeperWindow, "ZO_DefaultButton")
  switchViews:SetAnchor(BOTTOMLEFT, ShopkeeperWindow, BOTTOMLEFT, 20, -5)
  switchViews:SetWidth(175)
  switchViews:SetText(Shopkeeper.translate('viewModeAllName'))
  switchViews:SetHandler("OnClicked", Shopkeeper.SwitchViewMode)

  local miniSwitchViews = CreateControlFromVirtual("ShopkeeperMiniSwitchViewButton", ShopkeeperMiniWindow, "ZO_DefaultButton")
  miniSwitchViews:SetAnchor(BOTTOMLEFT, ShopkeeperMiniWindow, BOTTOMLEFT, 2, -5)
  miniSwitchViews:SetWidth(175)
  miniSwitchViews:SetText(Shopkeeper.translate('viewModeAllName'))
  miniSwitchViews:SetHandler("OnClicked", Shopkeeper.SwitchViewMode)

  -- Total / unit price switch button
  local unitPrice = CreateControlFromVirtual("ShopkeeperPriceSwitchButton", ShopkeeperWindow, "ZO_DefaultButton")
  unitPrice:SetAnchor(LEFT, switchViews, RIGHT, 0, 0)
  unitPrice:SetWidth(175)
  if Shopkeeper.savedVariables.showUnitPrice then
    unitPrice:SetText(Shopkeeper.translate('showTotalPrice'))
  else
    unitPrice:SetText(Shopkeeper.translate('showUnitPrice'))
  end
  unitPrice:SetHandler("OnClicked", Shopkeeper.SwitchPriceMode)

  local miniUnitPrice = CreateControlFromVirtual("ShopkeeperMiniPriceSwitchButton", ShopkeeperMiniWindow, "ZO_DefaultButton")
  miniUnitPrice:SetAnchor(LEFT, miniSwitchViews, RIGHT, 0, 0)
  miniUnitPrice:SetWidth(175)
  if Shopkeeper.savedVariables.showUnitPrice then
    miniUnitPrice:SetText(Shopkeeper.translate('showTotalPrice'))
  else
    miniUnitPrice:SetText(Shopkeeper.translate('showUnitPrice'))
  end
  miniUnitPrice:SetHandler("OnClicked", Shopkeeper.SwitchPriceMode)

  -- Refresh button
  local refreshButton = CreateControlFromVirtual("ShopkeeperRefreshButton", ShopkeeperWindow, "ZO_DefaultButton")
  refreshButton:SetAnchor(BOTTOMRIGHT, ShopkeeperWindow, BOTTOMRIGHT, -20, -5)
  refreshButton:SetWidth(150)
  refreshButton:SetText(Shopkeeper.translate('refreshLabel'))
  refreshButton:SetHandler("OnClicked", Shopkeeper.DoRefresh)
  local miniRefreshButton = CreateControlFromVirtual("ShopkeeperMiniRefreshButton", ShopkeeperMiniWindow, "ZO_DefaultButton")
  miniRefreshButton:SetAnchor(BOTTOMRIGHT, ShopkeeperMiniWindow, BOTTOMRIGHT, -2, -5)
  miniRefreshButton:SetWidth(150)
  miniRefreshButton:SetText(Shopkeeper.translate('refreshLabel'))
  miniRefreshButton:SetHandler("OnClicked", Shopkeeper.DoRefresh)

  -- Reset button
  local resetButton = CreateControlFromVirtual("ShopkeeperResetButton", ShopkeeperWindow, "ZO_DefaultButton")
  resetButton:SetAnchor(BOTTOMRIGHT, ShopkeeperWindow, BOTTOMRIGHT, -170, -5)
  resetButton:SetWidth(150)
  resetButton:SetText(Shopkeeper.translate('resetLabel'))
  resetButton:SetHandler("OnClicked", Shopkeeper.DoReset)
  local miniResetButton = CreateControlFromVirtual("ShopkeeperMiniResetButton", ShopkeeperMiniWindow, "ZO_DefaultButton")
  miniResetButton:SetAnchor(BOTTOMRIGHT, ShopkeeperMiniWindow, BOTTOMRIGHT, -152, -5)
  miniResetButton:SetWidth(150)
  miniResetButton:SetText(Shopkeeper.translate('resetLabel'))
  miniResetButton:SetHandler("OnClicked", Shopkeeper.DoReset)

  -- Make the 15 rows that comprise the visible table
  if #Shopkeeper.DataRows == 0 then
    local dataRowOffsetX = 25
    local dataRowOffsetY = 74
    for i = 1,15 do
      local dRow = CreateControlFromVirtual("ShopkeeperDataRow", ShopkeeperWindow, "ShopkeeperDataRow", i)
      dRow:SetSimpleAnchorParent(dataRowOffsetX, dataRowOffsetY+((dRow:GetHeight()+2)*(i-1)))
      Shopkeeper.DataRows[i] = dRow
    end
  end

  -- And for the mini window
  if #Shopkeeper.MiniDataRows == 0 then
    local dataRowOffsetX = 10
    local dataRowOffsetY = 74
    for i = 1,8 do
      local dRow = CreateControlFromVirtual("ShopkeeperMiniDataRow", ShopkeeperMiniWindow, "ShopkeeperMiniDataRow", i)
      dRow:SetSimpleAnchorParent(dataRowOffsetX, dataRowOffsetY+((dRow:GetHeight()+2)*(i-1)))
      Shopkeeper.MiniDataRows[i] = dRow
    end
  end

  -- Close buttons
  ShopkeeperWindowCloseButton:SetNormalTexture("/esoui/art/hud/radialicon_cancel_up.dds")
  ShopkeeperWindowCloseButton:SetMouseOverTexture("/esoui/art/hud/radialicon_cancel_over.dds")
  ShopkeeperWindowCloseButton:SetHandler("OnClicked", function() ShopkeeperWindow:SetHidden(true) end)
  ShopkeeperMiniWindowCloseButton:SetNormalTexture("/esoui/art/hud/radialicon_cancel_up.dds")
  ShopkeeperMiniWindowCloseButton:SetMouseOverTexture("/esoui/art/hud/radialicon_cancel_over.dds")
  ShopkeeperMiniWindowCloseButton:SetHandler("OnClicked", function() ShopkeeperMiniWindow:SetHidden(true) end)
  ShopkeeperStatsWindowCloseButton:SetNormalTexture("/esoui/art/hud/radialicon_cancel_up.dds")
  ShopkeeperStatsWindowCloseButton:SetMouseOverTexture("/esoui/art/hud/radialicon_cancel_over.dds")
  ShopkeeperStatsWindowCloseButton:SetHandler("OnClicked", function() ShopkeeperStatsWindow:SetHidden(true) end)

  -- Stats buttons
  ShopkeeperWindowStatsButton:SetNormalTexture("/esoui/art/tradinghouse/tradinghouse_listings_tabicon_up.dds")
  ShopkeeperWindowStatsButton:SetMouseOverTexture("/esoui/art/tradinghouse/tradinghouse_listings_tabicon_over.dds")
  ShopkeeperWindowStatsButton:SetHandler("OnClicked", Shopkeeper.ToggleShopkeeperStatsWindow)
  ShopkeeperWindowStatsButton:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('statsTooltip'))
  end)
  ShopkeeperWindowStatsButton:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)
  ShopkeeperMiniWindowStatsButton:SetNormalTexture("/esoui/art/tradinghouse/tradinghouse_listings_tabicon_up.dds")
  ShopkeeperMiniWindowStatsButton:SetMouseOverTexture("/esoui/art/tradinghouse/tradinghouse_listings_tabicon_over.dds")
  ShopkeeperMiniWindowStatsButton:SetHandler("OnClicked", Shopkeeper.ToggleShopkeeperStatsWindow)
  ShopkeeperMiniWindowStatsButton:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('statsTooltip'))
  end)
  ShopkeeperMiniWindowStatsButton:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)

  -- View size change buttons
  ShopkeeperWindowViewSizeButton:SetNormalTexture("/esoui/art/inventory/inventory_tabicon_quest_up.dds")
  ShopkeeperWindowViewSizeButton:SetMouseOverTexture("/esoui/art/inventory/inventory_tabicon_quest_over.dds")
  ShopkeeperWindowViewSizeButton:SetHandler("OnClicked", Shopkeeper.ToggleViewMode)
  ShopkeeperWindowViewSizeButton:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('sizeTooltip'))
  end)
  ShopkeeperWindowViewSizeButton:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)
  ShopkeeperMiniWindowViewSizeButton:SetNormalTexture("/esoui/art/inventory/inventory_tabicon_quest_up.dds")
  ShopkeeperMiniWindowViewSizeButton:SetMouseOverTexture("/esoui/art/inventory/inventory_tabicon_quest_over.dds")
  ShopkeeperMiniWindowViewSizeButton:SetHandler("OnClicked", Shopkeeper.ToggleViewMode)
  ShopkeeperMiniWindowViewSizeButton:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, Shopkeeper.translate('sizeTooltip'))
  end)
  ShopkeeperMiniWindowViewSizeButton:SetHandler("OnMouseExit", function(self) ZO_Tooltips_HideTextTooltip() end)


  -- Slider setup
  ShopkeeperWindow:SetHandler("OnMouseWheel", Shopkeeper.OnSliderMouseWheel)
  Shopkeeper.shopSlider = CreateControl(ShopkeeperWindowSlider, ShopkeeperWindow, CT_SLIDER)
  Shopkeeper.shopSlider.texture = "/esoui/art/miscellaneous/scrollbox_elevator.dds"
  Shopkeeper.shopSlider.offset = 0
  local tex = Shopkeeper.shopSlider.texture
  Shopkeeper.shopSlider:SetDimensions(20,561)
  Shopkeeper.shopSlider:SetMouseEnabled(true)
  Shopkeeper.shopSlider:SetThumbTexture(tex,tex,tex,20,50,0,0,1,1)
  Shopkeeper.shopSlider:SetMinMax(0, 100)
  Shopkeeper.shopSlider:SetValue(0)
  Shopkeeper.shopSlider:SetValueStep(1)
  Shopkeeper.shopSlider:SetAnchor(LEFT, ShopkeeperWindow, RIGHT, -20, 17)
  Shopkeeper.shopSlider:SetHandler("OnValueChanged", Shopkeeper.OnSliderMoved)
  Shopkeeper.sliderBG = CreateControl(nil, Shopkeeper.shopSlider, CT_BACKDROP)
  Shopkeeper.sliderBG:SetCenterColor(0, 0, 0)
  Shopkeeper.sliderBG:SetAnchor(TOPLEFT, Shopkeeper.shopSlider, TOPLEFT, 0, -4)
  Shopkeeper.sliderBG:SetAnchor(BOTTOMRIGHT, Shopkeeper.shopSlider, BOTTOMRIGHT, 0, 4)
  Shopkeeper.sliderBG:SetEdgeTexture("EsoUI\\Art\\Tooltips\\UI-SliderBackdrop.dds", 32, 4)

  ShopkeeperMiniWindow:SetHandler("OnMouseWheel", Shopkeeper.OnSliderMouseWheel)
  Shopkeeper.miniShopSlider = CreateControl(ShopkeeperMiniWindowSlider, ShopkeeperMiniWindow, CT_SLIDER)
  Shopkeeper.miniShopSlider.texture = "/esoui/art/miscellaneous/scrollbox_elevator.dds"
  Shopkeeper.miniShopSlider.offset = 0
  Shopkeeper.miniShopSlider:SetDimensions(20,300)
  Shopkeeper.miniShopSlider:SetMouseEnabled(true)
  Shopkeeper.miniShopSlider:SetThumbTexture(tex,tex,tex,20,50,0,0,1,1)
  Shopkeeper.miniShopSlider:SetMinMax(0, 100)
  Shopkeeper.miniShopSlider:SetValue(0)
  Shopkeeper.miniShopSlider:SetValueStep(1)
  Shopkeeper.miniShopSlider:SetAnchor(LEFT, ShopkeeperMiniWindow, RIGHT, -20, 17)
  Shopkeeper.miniShopSlider:SetHandler("OnValueChanged", Shopkeeper.OnSliderMoved)
  Shopkeeper.miniSliderBG = CreateControl(nil, Shopkeeper.miniShopSlider, CT_BACKDROP)
  Shopkeeper.miniSliderBG:SetCenterColor(0, 0, 0)
  Shopkeeper.miniSliderBG:SetAnchor(TOPLEFT, Shopkeeper.miniShopSlider, TOPLEFT, 0, -4)
  Shopkeeper.miniSliderBG:SetAnchor(BOTTOMRIGHT, Shopkeeper.miniShopSlider, BOTTOMRIGHT, 0, 4)
  Shopkeeper.miniSliderBG:SetEdgeTexture("EsoUI\\Art\\Tooltips\\UI-SliderBackdrop.dds", 32, 4)

  Shopkeeper.statsSlider = CreateControl(ShopkeeperStatsWindowSlider, ShopkeeperStatsWindow, CT_SLIDER)
  Shopkeeper.statsSlider.texture = "/esoui/art/miscellaneous/scrollbox_elevator.dds"
  Shopkeeper.statsSlider.offset = 0
  Shopkeeper.statsSlider:SetDimensions(375,14)
  Shopkeeper.statsSlider:SetMouseEnabled(true)
  Shopkeeper.statsSlider:SetThumbTexture("EsoUI\\Art\\Miscellaneous\\scrollbox_elevator.dds", "EsoUI\\Art\\Miscellaneous\\scrollbox_elevator_disabled.dds", nil, 8, 16)
  Shopkeeper.statsSlider:SetMinMax(0, 30)
  Shopkeeper.statsSlider:SetValue(0)
  Shopkeeper.statsSlider:SetValueStep(1)
  Shopkeeper.statsSlider:SetOrientation(ORIENTATION_HORIZONTAL)
  Shopkeeper.statsSlider:SetAnchor(BOTTOM, ShopkeeperStatsWindow, BOTTOM, 25, -15)
  Shopkeeper.statsSlider:SetHandler("OnValueChanged", Shopkeeper.OnStatsSliderMoved)
  Shopkeeper.statsSliderBG = CreateControl(nil, Shopkeeper.statsSlider, CT_BACKDROP)
  Shopkeeper.statsSliderBG:SetCenterColor(0, 0, 0)
  Shopkeeper.statsSliderBG:SetAnchor(TOPLEFT, Shopkeeper.statsSlider, TOPLEFT, 0, 4)
  Shopkeeper.statsSliderBG:SetAnchor(BOTTOMRIGHT, Shopkeeper.statsSlider, BOTTOMRIGHT, 0, -4)
  Shopkeeper.statsSliderBG:SetEdgeTexture("EsoUI\\Art\\Tooltips\\UI-SliderBackdrop.dds", 32, 4)


  -- Sorting handlers
  ShopkeeperWindowPrice:SetHandler("OnMouseUp", Shopkeeper.PriceSort)
  ShopkeeperWindowSellTime:SetHandler("OnMouseUp", Shopkeeper.TimeSort)
  ShopkeeperMiniWindowPrice:SetHandler("OnMouseUp", Shopkeeper.PriceSort)
  ShopkeeperMiniWindowSellTime:SetHandler("OnMouseUp", Shopkeeper.TimeSort)

  -- Search handler
  ZO_PreHookHandler(ShopkeeperWindowSearchBox, "OnTextChanged", function(self) Shopkeeper.DoSearch(ShopkeeperWindowSearchBox:GetText()) end)
  ZO_PreHookHandler(ShopkeeperMiniWindowSearchBox, "OnTextChanged", function(self) Shopkeeper.DoSearch(ShopkeeperMiniWindowSearchBox:GetText()) end)

  -- We're all set, so make sure we're using the right font and then update the UI
  Shopkeeper.windowFont = Shopkeeper.savedVariables.windowFont
  Shopkeeper:UpdateFonts()
  Shopkeeper.DisplayRows()
end

-- Init function
function Shopkeeper:Initialize()
  local Defaults =  {
    ["showChatAlerts"] = false,
    ["showMultiple"] = true,
    ["openWithMail"] = true,
    ["openWithStore"] = true,
    ["showFullPrice"] = true,
    ["winLeft"] = 30,
    ["winTop"] = 30,
    ["miniWinLeft"] = 30,
    ["miniWinTop"] = 30,
    ["statsWinLeft"] = 720,
    ["statsWinTop"] = 820,
    ["windowFont"] = "ProseAntique",
    ["historyDepth"] = 3000,
    ["scanFreq"] = 120,
    ["showAnnounceAlerts"] = true,
    ["alertSoundName"] = "Book_Acquired",
    ["showUnitPrice"] = false,
    ["viewSize"] = "full",
  }

  local acctDefaults = {
    ["lastScan"] = {},
    ["scanHistory"] = {},
  }

  self.savedVariables = ZO_SavedVars:New("ShopkeeperSavedVars", 1, Shopkeeper.GetAccountName(), Defaults)
  self.acctSavedVariables = ZO_SavedVars:NewAccountWide("ShopkeeperSavedVars", 1, Shopkeeper.GetAccountName(), acctDefaults)
  self.ScanResults = Shopkeeper.acctSavedVariables.scanHistory

  -- Update the lastScan value, as it was previously a number
  if type(Shopkeeper.acctSavedVariables.lastScan) == "number" then
    local guildNum = GetNumGuilds()
    local oldStamp = Shopkeeper.acctSavedVariables.lastScan
    Shopkeeper.acctSavedVariables.lastScan = {}
    for i = 1, guildNum do Shopkeeper.acctSavedVariables.lastScan[i] = oldStamp end
  end

  self:LibAddonInit()
  self:SetupShopkeeperWindow()
  self:RestoreWindowPosition()
  self.SortByTime("desc")

  -- We'll grab their locale now, it's really only used for a couple things as
  -- most localization is handled by the i18n/$(language).lua files
  Shopkeeper.locale = GetCVar('Language.2')
  if Shopkeeper.locale ~= "en" and Shopkeeper.locale ~= "de" and Shopkeeper.locale ~= "fr" then
    Shopkeeper.locale = "en"
  end

  -- Rather than constantly managing the length of the history, we'll just
  -- truncate it once at init-time since we now have it sorted.  As a result
  -- it will fluctuate in size depending on how active guild stores are and
  -- how long someone plays for at a time, but that's OK as it shouldn't impact
  -- performance too severely unless someone plays for 24+ hours straight
  local historyDepth = self.savedVariables.historyDepth
  if #self.ScanResults > historyDepth then
    for i = (historyDepth + 1), #self.ScanResults do
      table.remove(self.ScanResults)
    end
  end

  -- Now that we've truncated, populate the SelfSales table
  local loggedInAccount = string.lower(Shopkeeper.GetAccountName())
  for i = 1, #self.ScanResults do
    if string.lower(self.ScanResults[i][8]) == loggedInAccount then table.insert(self.SelfSales, self.ScanResults[i]) end
  end

  -- Populate the search table
  self.DoSearch(nil)

  -- Add the shopkeeper window to the mail and trading house scenes if the
  -- player's settings indicate they want that behavior
  Shopkeeper.uiFragment = ZO_FadeSceneFragment:New(ShopkeeperWindow)
  Shopkeeper.miniUiFragment = ZO_FadeSceneFragment:New(ShopkeeperMiniWindow)

  if self.savedVariables.openWithMail then
    if self.savedVariables.viewSize == "full" then
      MAIL_INBOX_SCENE:AddFragment(Shopkeeper.uiFragment)
      MAIL_SEND_SCENE:AddFragment(Shopkeeper.uiFragment)
    else
      MAIL_INBOX_SCENE:AddFragment(Shopkeeper.miniUiFragment)
      MAIL_SEND_SCENE:AddFragment(Shopkeeper.miniUiFragment)
    end
  end

  if self.savedVariables.openWithStore then
    if self.savedVariables.viewSize == "full" then
      TRADING_HOUSE_SCENE:AddFragment(Shopkeeper.uiFragment)
    else
      TRADING_HOUSE_SCENE:AddFragment(Shopkeeper.miniUiFragment)
    end
  end

  -- Because we allow manual toggling of the Shopkeeper window in those scenes (without
  -- making that setting permanent), we also have to hide the window on closing them
  -- if they're not part of the scene.
  EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_MAIL_CLOSE_MAILBOX, function()
    if not Shopkeeper.savedVariables.openWithMail then
      ShopkeeperWindow:SetHidden(true)
      ShopkeeperMiniWindow:SetHidden(true)
    end
  end)
  EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_CLOSE_TRADING_HOUSE, function()
    if not Shopkeeper.savedVariables.openWithStore then
      ShopkeeperWindow:SetHidden(true)
      ShopkeeperMiniWindow:SetHidden(true)
    end
  end)

  -- We also want to make sure the Shopkeeper windows are hidden in the game menu
  ZO_PreHookHandler(ZO_GameMenu_InGame, "OnShow", function()
    ShopkeeperWindow:SetHidden(true)
    ShopkeeperStatsWindow:SetHidden(true)
    ShopkeeperMiniWindow:SetHidden(true)
  end)

  -- I could do this with action layer pop/push, but it's kind've a pain
  -- when it's just these I want to hook
  EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_CLOSE_BANK, function()
    ShopkeeperWindow:SetHidden(true)
    ShopkeeperMiniWindow:SetHidden(true)
  end)
  EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_CLOSE_GUILD_BANK, function()
    ShopkeeperWindow:SetHidden(true)
    ShopkeeperMiniWindow:SetHidden(true)
  end)
  EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_CLOSE_STORE, function()
    ShopkeeperWindow:SetHidden(true)
    ShopkeeperMiniWindow:SetHidden(true)
  end)
  EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_END_CRAFTING_STATION_INTERACT, function()
    ShopkeeperWindow:SetHidden(true)
    ShopkeeperMiniWindow:SetHidden(true)
  end)

  -- Update fonts after each UI load
  EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_PLAYER_ACTIVATED, Shopkeeper.PlayerActive)

  -- RegisterForUpdate lets us scan at a given interval (in ms), so we'll use that to
  -- keep the sales history updated
  local scanInterval = self.savedVariables.scanFreq * 1000
  EVENT_MANAGER:RegisterForUpdate(Shopkeeper.name, scanInterval, function() Shopkeeper:ScanStores(false, false) end)

  -- Right, we're all set up, so give the client a few seconds to catch up on everything
  -- and then do an initial (deep) scan in case it's been a while since the player
  -- logged on.
  zo_callLater(function() Shopkeeper:ScanStores(true, false) end, 5000)

end

-- Event handler for the OnAddOnLoaded event
function Shopkeeper.OnAddOnLoaded(event, addonName)
  if addonName == Shopkeeper.name then
    Shopkeeper:Initialize()
  end
end

-- Register for the OnAddOnLoaded event
EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_ADD_ON_LOADED, Shopkeeper.OnAddOnLoaded)

-- Set up /shopkeeper as a slash command toggle for the main window
SLASH_COMMANDS["/shopkeeper"] = function()
  if ShopkeeperWindow:IsHidden() then
    Shopkeeper.DisplayRows()
    SetGameCameraUIMode(true)
  end

  ShopkeeperWindow:SetHidden(not ShopkeeperWindow:IsHidden())
end