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

-- 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.acctSavedVariables.allSettingsAccount and Shopkeeper.acctSavedVariables.showUnitPrice) or
       (not self.acctSavedVariables.allSettingsAccount and 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.acctSavedVariables.allSettingsAccount and Shopkeeper.acctSavedVariables.showUnitPrice) or
       (not self.acctSavedVariables.allSettingsAccount and 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

-- Calculate some stats based on the player's sales
-- And return them as a table.
function Shopkeeper.SalesStats(statsDays)
  local itemsSold = {["SK_STATS_TOTAL"] = 0}
  local goldMade = {["SK_STATS_TOTAL"] = 0}
  local largestSingle = {["SK_STATS_TOTAL"] = {0, nil}}
  local oldestTime = 0
  local newestTime = 0
  local overallOldestTime = 0
  local kioskSales = {["SK_STATS_TOTAL"] = 0}

  local guildDropdown = ZO_ComboBox_ObjectFromContainer(ShopkeeperStatsGuildChooser)
  guildDropdown:ClearItems()
  local allGuilds = guildDropdown:CreateItemEntry(GetString(SK_STATS_ALL_GUILDS), function() Shopkeeper.UpdateStatsWindow("SK_STATS_TOTAL") end)
  guildDropdown:AddItem(allGuilds)

  -- 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]
    local theItemGuild = theItem[2]
    if statsDays == 0 or theItem[6] > statsDaysEpoch then
      itemsSold["SK_STATS_TOTAL"] = itemsSold["SK_STATS_TOTAL"] + 1
      if itemsSold[theItemGuild] ~= nil then
        itemsSold[theItemGuild] = itemsSold[theItemGuild] + 1
      else
        itemsSold[theItemGuild] = 1
      end

      if #theItem > 8 and theItem[9] then
        kioskSales["SK_STATS_TOTAL"] = kioskSales["SK_STATS_TOTAL"] + 1
        if kioskSales[theItemGuild] ~= nil then
          kioskSales[theItemGuild] = kioskSales[theItemGuild] + 1
        else
          kioskSales[theItemGuild] = 1
        end
      end

      goldMade["SK_STATS_TOTAL"] = goldMade["SK_STATS_TOTAL"] + theItem[7]
      if goldMade[theItemGuild] ~= nil then
        goldMade[theItemGuild] = goldMade[theItemGuild] + theItem[7]
      else
        goldMade[theItemGuild] = theItem[7]
      end

      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["SK_STATS_TOTAL"][1] then largestSingle["SK_STATS_TOTAL"] = {theItem[7], theItem[3]} end
      if largestSingle[theItemGuild] == nil or theItem[7] > largestSingle[theItemGuild][1] then
        largestSingle[theItemGuild] = {theItem[7], theItem[3]}
      end
    end

    if overallOldestTime == 0 or theItem[6] < overallOldestTime then
      overallOldestTime = theItem[6]
    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 overallTimeWindow = newestTime - overallOldestTime
  local overallDayWindow = 1
  if overallTimeWindow > 86400 then overallDayWindow = math.floor(overallTimeWindow / 86400) + 1 end

  local goldPerDay = {}
  local kioskPercentage = {}
  local showFullPrice = Shopkeeper.savedVariables.showFullPrice
  if Shopkeeper.acctSavedVariables.allSettingsAccount then showFullPrice = Shopkeeper.acctSavedVariables.showFullPrice end

  for theGuildName, guildItemsSold in pairs(itemsSold) do
    goldPerDay[theGuildName] = math.floor(goldMade[theGuildName] / dayWindow)
    local kioskSalesTemp = 0
    if kioskSales[theGuildName] ~= nil then kioskSalesTemp = kioskSales[theGuildName] end
    kioskPercentage[theGuildName] = math.floor((kioskSalesTemp / guildItemsSold) * 100)

    if theGuildName ~= "SK_STATS_TOTAL" then
      local guildEntry = guildDropdown:CreateItemEntry(theGuildName, function() Shopkeeper.UpdateStatsWindow(theGuildName) end)
      guildDropdown:AddItem(guildEntry)
    end

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

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

-- Update all the fields of the stats window based on the response from SalesStats()
function Shopkeeper.UpdateStatsWindow(guildName)
  local sliderLevel = ShopkeeperStatsWindowSlider:GetValue()
  Shopkeeper.newStats = Shopkeeper.SalesStats(sliderLevel)

  -- Hide the slider if there's less than a day of data
  -- and set the slider's range for the new day range returned
  ShopkeeperStatsWindowSliderLabel:SetHidden(false)
  ShopkeeperStatsWindowSlider:SetHidden(false)
  if Shopkeeper.newStats['totalDays'] < 2 then
    ShopkeeperStatsWindowSlider:SetHidden(true)
    ShopkeeperStatsWindowSliderLabel:SetHidden(true)
    sliderLevel = 0
  elseif sliderLevel > (Shopkeeper.newStats['totalDays'] - 1) then
    sliderLevel = 0
  end
  ShopkeeperStatsWindowSlider:SetMinMax(0, (Shopkeeper.newStats['totalDays'] - 1))

  -- Set the time range label appropriately
  if sliderLevel == 0 then
    ShopkeeperStatsWindowSliderSettingLabel:SetText(GetString(SK_STATS_TIME_ALL))
  else
    ShopkeeperStatsWindowSliderSettingLabel:SetText(zo_strformat(GetString(SK_STATS_TIME_SOME), sliderLevel))
  end

  -- Grab which guild is selected
  local guildSelected = GetString(SK_STATS_ALL_GUILDS)
  if guildName ~= "SK_STATS_TOTAL" then guildSelected = guildName end
  local guildDropdown = ZO_ComboBox_ObjectFromContainer(ShopkeeperStatsGuildChooser)
  guildDropdown:SetSelectedItem(guildSelected)

  -- And set the rest of the stats window up with data from the appropriate
  -- guild (or overall data)
  ShopkeeperStatsWindowItemsSoldLabel:SetText(string.format(GetString(SK_STATS_ITEMS_SOLD), Shopkeeper.localizedNumber(Shopkeeper.newStats['numSold'][guildName]), Shopkeeper.newStats['kioskPercent'][guildName]))
  ShopkeeperStatsWindowTotalGoldLabel:SetText(string.format(GetString(SK_STATS_TOTAL_GOLD), Shopkeeper.localizedNumber(Shopkeeper.newStats['totalGold'][guildName]), Shopkeeper.localizedNumber(Shopkeeper.newStats['avgGold'][guildName])))
  ShopkeeperStatsWindowBiggestSaleLabel:SetText(string.format(GetString(SK_STATS_BIGGEST), zo_strformat("<<t:1>>", Shopkeeper.newStats['biggestSale'][guildName][2]), Shopkeeper.localizedNumber(Shopkeeper.newStats['biggestSale'][guildName][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 settingsToUse = Shopkeeper.savedVariables
      if Shopkeeper.acctSavedVariables.allSettingsAccount then settingsToUse = Shopkeeper.acctSavedVariables end
      local optionsData = {
        -- Sound and Alert options
        [1] = {
          type = "submenu",
          name = GetString(SK_ALERT_OPTIONS_NAME),
          tooltip = GetString(SK_ALERT_OPTIONS_TIP),
          controls = {
            -- On-Screen Alerts
            [1] = {
              type = "checkbox",
              name = GetString(SK_ALERT_ANNOUNCE_NAME),
              tooltip = GetString(SK_ALERT_ANNOUNCE_TIP),
              getFunc = function()
                if Shopkeeper.acctSavedVariables.allSettingsAccount then
                  return Shopkeeper.acctSavedVariables.showAnnounceAlerts
                else return Shopkeeper.savedVariables.showAnnounceAlerts end
              end,
              setFunc = function(value)
                if Shopkeeper.acctSavedVariables.allSettingsAccount then
                  Shopkeeper.acctSavedVariables.showAnnounceAlerts = value
                else Shopkeeper.savedVariables.showAnnounceAlerts = value end
              end,
            },
            -- Chat Alerts
            [2] = {
              type = "checkbox",
              name = GetString(SK_ALERT_CHAT_NAME),
              tooltip = GetString(SK_ALERT_CHAT_TIP),
              getFunc = function()
                if Shopkeeper.acctSavedVariables.allSettingsAccount then
                  return Shopkeeper.acctSavedVariables.showChatAlerts
                else return Shopkeeper.savedVariables.showChatAlerts end
              end,
              setFunc = function(value)
                if Shopkeeper.acctSavedVariables.allSettingsAccount then
                  Shopkeeper.acctSavedVariables.showChatAlerts = value
                else Shopkeeper.savedVariables.showChatAlerts = value end
              end,
            },
            -- Sound to use for alerts
            [3] = {
              type = "dropdown",
              name = GetString(SK_ALERT_TYPE_NAME),
              tooltip = GetString(SK_ALERT_TYPE_TIP),
              choices = Shopkeeper.soundKeys(),
              getFunc = function()
                if Shopkeeper.acctSavedVariables.allSettingsAccount then
                  return Shopkeeper.searchSounds(Shopkeeper.acctSavedVariables.alertSoundName)
                else return Shopkeeper.searchSounds(Shopkeeper.savedVariables.alertSoundName) end
              end,
              setFunc = function(value)
                if Shopkeeper.acctSavedVariables.allSettingsAccount then
                  Shopkeeper.acctSavedVariables.alertSoundName = Shopkeeper.searchSoundNames(value)
                else Shopkeeper.savedVariables.alertSoundName = Shopkeeper.searchSoundNames(value) end
                PlaySound(Shopkeeper.searchSoundNames(value))
              end,
            },
            -- Whether or not to show multiple alerts for multiple sales
            [4] = {
              type = "checkbox",
              name = GetString(SK_MULT_ALERT_NAME),
              tooltip = GetString(SK_MULT_ALERT_TIP),
              getFunc = function()
                if Shopkeeper.acctSavedVariables.allSettingsAccount then
                  return Shopkeeper.acctSavedVariables.showMultiple
                else return Shopkeeper.savedVariables.showMultiple end
              end,
              setFunc = function(value)
                if Shopkeeper.acctSavedVariables.allSettingsAccount then
                  Shopkeeper.acctSavedVariables.showMultiple = value
                else Shopkeeper.savedVariables.showMultiple = value end
              end,
            },
          },
        },
        -- Open main window with mailbox scenes
        [2] = {
          type = "checkbox",
          name = GetString(SK_OPEN_MAIL_NAME),
          tooltip = GetString(SK_OPEN_MAIL_TIP),
          getFunc = function()
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              return Shopkeeper.acctSavedVariables.openWithMail
            else return Shopkeeper.savedVariables.openWithMail end
          end,
          setFunc = function(value)
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              Shopkeeper.acctSavedVariables.openWithMail = value
            else Shopkeeper.savedVariables.openWithMail = value end

            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,
        },
        -- Open main window with trading house scene
        [3] = {
          type = "checkbox",
          name = GetString(SK_OPEN_STORE_NAME),
          tooltip = GetString(SK_OPEN_STORE_TIP),
          getFunc = function()
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              return Shopkeeper.acctSavedVariables.openWithStore
            else return Shopkeeper.savedVariables.openWithStore end
          end,
          setFunc = function(value)
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              Shopkeeper.acctSavedVariables.openWithStore = value
            else Shopkeeper.savedVariables.openWithStore = value end

            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,
        },
        -- Show full sale price or post-tax price
        [4] = {
          type = "checkbox",
          name = GetString(SK_FULL_SALE_NAME),
          tooltip = GetString(SK_FULL_SALE_TIP),
          getFunc = function()
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              return Shopkeeper.acctSavedVariables.showFullPrice
            else return Shopkeeper.savedVariables.showFullPrice end
          end,
          setFunc = function(value)
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              Shopkeeper.acctSavedVariables.showFullPrice = value
            else Shopkeeper.savedVariables.showFullPrice = value end
            Shopkeeper.DisplayRows()
          end,
        },
        -- Scan frequency (in seconds)
        [5] = {
          type = "slider",
          name = GetString(SK_SCAN_FREQ_NAME),
          tooltip = GetString(SK_SCAN_FREQ_TIP),
          min = 60,
          max = 600,
          getFunc = function()
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              return Shopkeeper.acctSavedVariables.scanFreq
            else return Shopkeeper.savedVariables.scanFreq end
          end,
          setFunc = function(value)
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              Shopkeeper.acctSavedVariables.scanFreq = value
            else Shopkeeper.savedVariables.scanFreq = value end

            EVENT_MANAGER:UnregisterForUpdate(Shopkeeper.name)
            local scanInterval = value * 1000
            EVENT_MANAGER:RegisterForUpdate(Shopkeeper.name, scanInterval, function() Shopkeeper:ScanStores(false) end)
          end,
        },
        -- Size of sales history
        [6] = {
          type = "slider",
          name = GetString(SK_HISTORY_DEPTH_NAME),
          tooltip = GetString(SK_HISTORY_DEPTH_TIP),
          min = 500,
          max = 7500,
          getFunc = function()
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              return Shopkeeper.acctSavedVariables.historyDepth
            else return Shopkeeper.savedVariables.historyDepth end
          end,
          setFunc = function(value)
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              Shopkeeper.acctSavedVariables.historyDepth = value
            else Shopkeeper.savedVariables.historyDepth = value end
          end,
        },
        -- Font to use
        [7] = {
          type = "dropdown",
          name = GetString(SK_WINDOW_FONT_NAME),
          tooltip = GetString(SK_WINDOW_FONT_TIP),
          choices = LMP:List(LMP.MediaType.FONT),
          getFunc = function()
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              return Shopkeeper.acctSavedVariables.windowFont
            else return Shopkeeper.savedVariables.windowFont end
          end,
          setFunc = function(value)
            if Shopkeeper.acctSavedVariables.allSettingsAccount then
              Shopkeeper.acctSavedVariables.windowFont = value
            else Shopkeeper.savedVariables.windowFont = value end
            Shopkeeper.UpdateFonts()
          end,
        },
        -- Make all settings account-wide (or not)
        [8] = {
          type = "checkbox",
          name = GetString(SK_ACCOUNT_WIDE_NAME),
          tooltip = GetString(SK_ACCOUNT_WIDE_TIP),
          getFunc = function() return Shopkeeper.acctSavedVariables.allSettingsAccount end,
          setFunc = function(value)
            if value then
              Shopkeeper.acctSavedVariables.showChatAlerts = Shopkeeper.savedVariables.showChatAlerts
              Shopkeeper.acctSavedVariables.showChatAlerts = Shopkeeper.savedVariables.showMultiple
              Shopkeeper.acctSavedVariables.openWithMail = Shopkeeper.savedVariables.openWithMail
              Shopkeeper.acctSavedVariables.openWithStore = Shopkeeper.savedVariables.openWithStore
              Shopkeeper.acctSavedVariables.showFullPrice = Shopkeeper.savedVariables.showFullPrice
              Shopkeeper.acctSavedVariables.winLeft = Shopkeeper.savedVariables.winLeft
              Shopkeeper.acctSavedVariables.winTop = Shopkeeper.savedVariables.winTop
              Shopkeeper.acctSavedVariables.miniWinLef = Shopkeeper.savedVariables.miniWinLeft
              Shopkeeper.acctSavedVariables.miniWinTop = Shopkeeper.savedVariables.miniWinTop
              Shopkeeper.acctSavedVariables.statsWinLeft = Shopkeeper.savedVariables.statsWinLeft
              Shopkeeper.acctSavedVariables.statsWinTop = Shopkeeper.savedVariables.statsWinTop
              Shopkeeper.acctSavedVariables.windowFont = Shopkeeper.savedVariables.windowFont
              Shopkeeper.acctSavedVariables.historyDepth = Shopkeeper.savedVariables.historyDepth
              Shopkeeper.acctSavedVariables.scanFreq = Shopkeeper.savedVariables.scanFreq
              Shopkeeper.acctSavedVariables.showAnnounceAlerts = Shopkeeper.savedVariables.showAnnounceAlerts
              Shopkeeper.acctSavedVariables.alertSoundName = Shopkeeper.savedVariables.alertSoundName
              Shopkeeper.acctSavedVariables.showUnitPrice = Shopkeeper.savedVariables.showUnitPrice
              Shopkeeper.acctSavedVariables.viewSize = Shopkeeper.savedVariables.viewSize
            else
              Shopkeeper.savedVariables.showChatAlerts = Shopkeeper.acctSavedVariables.showChatAlerts
              Shopkeeper.savedVariables.showChatAlerts = Shopkeeper.acctSavedVariables.showMultiple
              Shopkeeper.savedVariables.openWithMail = Shopkeeper.acctSavedVariables.openWithMail
              Shopkeeper.savedVariables.openWithStore = Shopkeeper.acctSavedVariables.openWithStore
              Shopkeeper.savedVariables.showFullPrice = Shopkeeper.acctSavedVariables.showFullPrice
              Shopkeeper.savedVariables.winLeft = Shopkeeper.acctSavedVariables.winLeft
              Shopkeeper.savedVariables.winTop = Shopkeeper.acctSavedVariables.winTop
              Shopkeeper.savedVariables.miniWinLef = Shopkeeper.acctSavedVariables.miniWinLeft
              Shopkeeper.savedVariables.miniWinTop = Shopkeeper.acctSavedVariables.miniWinTop
              Shopkeeper.savedVariables.statsWinLeft = Shopkeeper.acctSavedVariables.statsWinLeft
              Shopkeeper.savedVariables.statsWinTop = Shopkeeper.acctSavedVariables.statsWinTop
              Shopkeeper.savedVariables.windowFont = Shopkeeper.acctSavedVariables.windowFont
              Shopkeeper.savedVariables.historyDepth = Shopkeeper.acctSavedVariables.historyDepth
              Shopkeeper.savedVariables.scanFreq = Shopkeeper.acctSavedVariables.scanFreq
              Shopkeeper.savedVariables.showAnnounceAlerts = Shopkeeper.acctSavedVariables.showAnnounceAlerts
              Shopkeeper.savedVariables.alertSoundName = Shopkeeper.acctSavedVariables.alertSoundName
              Shopkeeper.savedVariables.showUnitPrice = Shopkeeper.acctSavedVariables.showUnitPrice
              Shopkeeper.savedVariables.viewSize = Shopkeeper.acctSavedVariables.viewSize
            end
            Shopkeeper.acctSavedVariables.allSettingsAccount = value
          end,
        },
      }

      -- And make the options panel
      LAM:RegisterOptionControls("ShopkeeperOptions", optionsData)
    end
  end
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 = GetDisplayName()
  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(GetString(SK_VIEW_YOUR_SALES))
    ShopkeeperWindowTitle:SetText("Shopkeeper - " .. GetString(SK_ALL_SALES_TITLE))
    ShopkeeperMiniSwitchViewButton:SetText(GetString(SK_VIEW_YOUR_SALES))
    ShopkeeperMiniWindowTitle:SetText("Shopkeeper - " .. GetString(SK_ALL_SALES_TITLE))
    Shopkeeper.viewMode = "all"
  else
    ShopkeeperSwitchViewButton:SetText(GetString(SK_VIEW_ALL_SALES))
    ShopkeeperWindowTitle:SetText("Shopkeeper - " .. GetString(SK_YOUR_SALES_TITLE))
    ShopkeeperMiniSwitchViewButton:SetText(GetString(SK_VIEW_ALL_SALES))
    ShopkeeperMiniWindowTitle:SetText("Shopkeeper - " .. GetString(SK_YOUR_SALES_TITLE))
    Shopkeeper.viewMode = "self"
  end

  if (Shopkeeper.acctSavedVariables.allSettingsAccount and Shopkeeper.acctSavedVariables.viewSize == "full") or
     (not Shopkeeper.acctSavedVariables.allSettingsAccount and Shopkeeper.savedVariables.viewSize == "full") then
    Shopkeeper.DoSearch(ShopkeeperWindowSearchBox:GetText())
  else
    Shopkeeper.DoSearch(ShopkeeperMiniWindowSearchBox:GetText())
  end
end

-- Switch between total price mode and unit price mode
function Shopkeeper.SwitchPriceMode()
  local settingsToUse = Shopkeeper.savedVariables
  if Shopkeeper.acctSavedVariables.allSettingsAccount then settingsToUse = Shopkeeper.acctSavedVariables end
  if settingsToUse.showUnitPrice then
    settingsToUse.showUnitPrice = false
    ShopkeeperPriceSwitchButton:SetText(GetString(SK_SHOW_UNIT))
    ShopkeeperWindowPrice:SetText(GetString(SK_PRICE_COLUMN))
    ShopkeeperMiniPriceSwitchButton:SetText(GetString(SK_SHOW_UNIT))
    ShopkeeperMiniWindowPrice:SetText(GetString(SK_PRICE_COLUMN))
  else
    settingsToUse.showUnitPrice = true
    ShopkeeperPriceSwitchButton:SetText(GetString(SK_SHOW_TOTAL))
    ShopkeeperWindowPrice:SetText(GetString(SK_PRICE_EACH_COLUMN))
    ShopkeeperMiniPriceSwitchButton:SetText(GetString(SK_SHOW_TOTAL))
    ShopkeeperMiniWindowPrice:SetText(GetString(SK_PRICE_EACH_COLUMN))
  end

  if Shopkeeper.curSort[1] == "price" then
    Shopkeeper.SortByPrice(Shopkeeper.curSort[2])
  else
    Shopkeeper.DisplayRows()
  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)
  local settingsToUse = Shopkeeper.savedVariables
  if Shopkeeper.acctSavedVariables.allSettingsAccount then settingsToUse = Shopkeeper.acctSavedVariables end
  if settingsToUse.viewSize == "full" then
    Shopkeeper.DoSearch(ShopkeeperWindowSearchBox:GetText())
  else
    Shopkeeper.DoSearch(ShopkeeperMiniWindowSearchBox:GetText())
  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
  ShopkeeperWindowSlider:SetMinMax(0, sliderMax)
  sliderMax = 0
  if #tableToUse > 8 then sliderMax = (#tableToUse - 8) end
  ShopkeeperMiniWindowSlider: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] " .. GetString(SK_REFRESH_DONE)) 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
    if settingsToUse.showChatAlerts or settingsToUse.showAnnounceAlerts then
      PlaySound(settingsToUse.alertSoundName)
    end

    local numSold = 0
    local totalGold = 0
    local numAlerts = #Shopkeeper.alertQueue
    local lastEvent = {}
    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 settingsToUse.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 settingsToUse.showMultiple or numAlerts == 1 then
        -- Insert thousands separators for the price
        local stringPrice = Shopkeeper.localizedNumber(dispPrice)

        -- On-screen alert
        if settingsToUse.showAnnounceAlerts then

          -- We'll add a numerical suffix to avoid queueing two identical messages in a row
          -- because the alerts will 'miss' if we do
          local textTime = Shopkeeper.textTimeSince(theEvent.saleTime, true)
          local alertSuffix = ""
          if lastEvent[1] ~= nil and theEvent.itemName == lastEvent[1].itemName and textTime == lastEvent[2] then
            lastEvent[3] = lastEvent[3] + 1
            alertSuffix = " (" .. lastEvent[3] .. ")"
          else
            lastEvent[1] = theEvent
            lastEvent[2] = textTime
            lastEvent[3] = 1
          end
          -- 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:AddMessage("ShopkeeperAlert", CSA_EVENT_SMALL_TEXT, SOUNDS.NONE,
                string.format(GetString(SK_SALES_ALERT_COLOR), theEvent.quant, zo_strformat("<<t:1>>", theEvent.itemName),
                              stringPrice, theEvent.guild, textTime) .. alertSuffix)
            else
              CENTER_SCREEN_ANNOUNCE:AddMessage("ShopkeeperAlert", CSA_EVENT_SMALL_TEXT, SOUNDS.NONE,
                string.format(GetString(SK_SALES_ALERT_SINGLE_COLOR),zo_strformat("<<t:1>>", theEvent.itemName),
                              stringPrice, theEvent.guild, textTime) .. alertSuffix)
            end
          else
            CENTER_SCREEN_ANNOUNCE:AddMessage("ShopkeeperAlert", CSA_EVENT_SMALL_TEXT, SOUNDS.NONE,
              string.format(GetString(SK_SALES_ALERT_COLOR), zo_strformat("<<t:1>>", theEvent.itemName),
                            theEvent.quant, stringPrice, theEvent.guild, textTime) .. alertSuffix)
          end
        end

        -- Chat alert
        if settingsToUse.showChatAlerts then
          if Shopkeeper.locale == "de" then
            if theEvent.quant > 1 then
              CHAT_SYSTEM:AddMessage(string.format("[Shopkeeper] " .. GetString(SK_SALES_ALERT),
                                     theEvent.quant, zo_strformat("<<t:1>>", theEvent.itemName), stringPrice, theEvent.guild, Shopkeeper.textTimeSince(theEvent.saleTime, true)))
            else
              CHAT_SYSTEM:AddMessage(string.format("[Shopkeeper] " .. GetString(SK_SALES_ALERT_SINGLE),
                                     zo_strformat("<<t:1>>", theEvent.itemName), stringPrice, theEvent.guild, Shopkeeper.textTimeSince(theEvent.saleTime, true)))
            end
          else
            CHAT_SYSTEM:AddMessage(string.format("[Shopkeeper] " .. GetString(SK_SALES_ALERT),
                                   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 settingsToUse.showMultiple and numAlerts > 1 then
      -- Insert thousands separators for the price
      local stringPrice = Shopkeeper.localizedNumber(totalGold)

      if settingsToUse.showAnnounceAlerts then
        CENTER_SCREEN_ANNOUNCE:AddMessage("ShopkeeperAlert", CSA_EVENT_SMALL_TEXT, settingsToUse.alertSoundName,
          string.format(GetString(SK_SALES_ALERT_GROUP_COLOR), numSold, stringPrice))
      else
        CHAT_SYSTEM:AddMessage(string.format("[Shopkeeper] " .. GetString(SK_SALES_ALERT_GROUP),
                               numSold, stringPrice))
      end
    end
  end

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

-- Makes sure all the necessary data is there, and adds the passed-in event theEvent
-- to the ScanResults and SelfSales table.  If doAlert is true, also adds it to
-- alertQueue, which means an alert may fire during PostScan.
function Shopkeeper:InsertEvent(theEvent, doAlert)
  local thePlayer = string.lower(GetDisplayName())
  local settingsToUse = Shopkeeper.savedVariables
  if Shopkeeper.acctSavedVariables.allSettingsAccount then settingsToUse = Shopkeeper.acctSavedVariables end

  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)

    -- 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, theEvent.kioskSale})

    -- 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, theEvent.kioskSale})
      if doAlert and (settingsToUse.showChatAlerts or settingsToUse.showAnnounceAlerts) then
        table.insert(Shopkeeper.alertQueue, theEvent)
      end
    end
  end
end

-- Actually carries out of the scan of a specific guild store's sales history.
-- Grabs all the members of the guild first to determine if a sale came from the
-- guild's kiosk (guild trader) or not.
-- Calls InsertEvent to actually insert the event into the ScanResults and SelfSales
-- tables.
-- Afterwards, will start scan of the next guild or call postscan if no more guilds.
function Shopkeeper:DoScan(guildNum, checkOlder, doAlert)
  local guildID = GetGuildId(guildNum)
  local numEvents = GetNumGuildEvents(guildID, GUILD_HISTORY_SALES)
  local guildName = GetGuildName(guildID)
  local prevEvents = 0
  if Shopkeeper.numEvents[guildName] ~= nil then prevEvents = Shopkeeper.numEvents[guildName] end

  if numEvents > prevEvents then
    local guildMemberInfo = {}
    -- Index the table with the account names themselves as they're
    -- (hopefully!) unique - search much faster
    for i = 1, GetNumGuildMembers(guildID) do
      local guildMemInfo, _, _, _, _ = GetGuildMemberInfo(guildID, i)
      guildMemberInfo[string.lower(guildMemInfo)] = true
    end

    for i = (prevEvents + 1), numEvents do
      local theEvent = {}
      _, theEvent.secsSince, theEvent.seller, theEvent.buyer,
      theEvent.quant, theEvent.itemName, theEvent.salePrice, _ = GetGuildEventInfo(guildID, GUILD_HISTORY_SALES, i)
      theEvent.guild = guildName
      theEvent.saleTime = GetTimeStamp() - theEvent.secsSince

      -- If we didn't add an entry to guildMemberInfo earlier setting the
      -- buyer's name index to true, then this was either bought at a kiosk
      -- or the buyer left the guild after buying but before we scanned.
      -- Close enough!
      theEvent.kioskSale = (guildMemberInfo[string.lower(theEvent.buyer)] == nil)

      -- If we're doing a deep scan, revert to timestamp checking
      -- For reasons I cannot determine, I am getting items not previously
      -- seen up to 10 seconds BEFORE the last call to RequestGuildHistoryCategoryNewest.
      -- Bizarre, but adding this fudge factor hasn't resulted in dupes...yet.
      if checkOlder then
        if Shopkeeper.acctSavedVariables.lastScan[guildName] == nil or GetDiffBetweenTimeStamps(theEvent.saleTime, Shopkeeper.acctSavedVariables.lastScan[guildName]) >= -8 then
          Shopkeeper:InsertEvent(theEvent, false)
        end

      -- Otherwise, all new events are assumed good
      -- Inspiration for event number-based handling from sirinsidiator
      else
        Shopkeeper:InsertEvent(theEvent, true)
      end
    end
  end

  -- We got through any new (to us) events, so update the timestamp and number of events
  Shopkeeper.acctSavedVariables.lastScan[guildName] = Shopkeeper.requestTimestamp
  Shopkeeper.numEvents[guildName] = numEvents

  -- If we have another guild to scan, see if we need to check older and scan it
  if guildNum < GetNumGuilds() then
    local nextGuild = guildNum + 1
    local nextGuildID = GetGuildId(nextGuild)
    local nextGuildName = GetGuildName(nextGuildID)

    -- Transition from numerical (old-style) indexing of last scan times
    -- May go away eventually
    if Shopkeeper.acctSavedVariables.lastScan[nextGuild] ~= nil then
      Shopkeeper.acctSavedVariables.lastScan[nextGuildName] = Shopkeeper.acctSavedVariables.lastScan[nextGuild]
      Shopkeeper.acctSavedVariables.lastScan[nextGuild] = nil
    end

    -- If we don't have any event info for the next guild, do a deep scan
    local nextCheckOlder = (Shopkeeper.numEvents[nextGuildName] == nil or Shopkeeper.numEvents[nextGuildName] == 0)
    Shopkeeper.requestTimestamp = GetTimeStamp()
    RequestGuildHistoryCategoryNewest(nextGuildID, GUILD_HISTORY_SALES)
    if nextCheckOlder then
      zo_callLater(function() Shopkeeper:ScanOlder(nextGuild, doAlert) end, 1500)
    else
      zo_callLater(function() Shopkeeper:DoScan(nextGuild, false, doAlert) end, 1500)
    end
  -- Otherwise, start the postscan routines
  else
    Shopkeeper.isScanning = false
    Shopkeeper:PostScan(doAlert)
  end
end

-- Repeatedly checks for older events until there aren't anymore,
-- then calls DoScan to pick up sales events
function Shopkeeper:ScanOlder(guildNum, doAlert)
  local guildID = GetGuildId(guildNum)
  local numEvents = GetNumGuildEvents(guildID, GUILD_HISTORY_SALES)
  if numEvents > 0 then
    if DoesGuildHistoryCategoryHaveMoreEvents(guildID, GUILD_HISTORY_SALES) then
      local newRequestTime = GetTimeStamp()
      RequestGuildHistoryCategoryOlder(guildID, GUILD_HISTORY_SALES)
      zo_callLater(function() Shopkeeper:ScanOlder(guildNum, doAlert) end, 1500)
    else
      zo_callLater(function() Shopkeeper:DoScan(guildNum, true, doAlert) end, 1500)
    end
  else
    zo_callLater(function() Shopkeeper:DoScan(guildNum, true, doAlert) end, 1500)
  end
end

-- Scans all stores a player has access to with delays between them.
function Shopkeeper:ScanStores(doAlert)
  -- If it's been less than 45 seconds 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() - 45
  local guildNum = GetNumGuilds()
  -- Nothing to scan!
  if guildNum == 0 then return end

  -- Grab some info about the first guild (since we now know there's at least one)
  local firstGuildID = GetGuildId(1)
  local firstGuildName = GetGuildName(firstGuildID)

  -- Transition from numerical (old-style) indexing of last scan times
  -- May go away at some point
  if Shopkeeper.acctSavedVariables.lastScan[1] ~= nil then
    Shopkeeper.acctSavedVariables.lastScan[firstGuildName] = Shopkeeper.acctSavedVariables.lastScan[1]
    Shopkeeper.acctSavedVariables.lastScan[1] = nil
  end

  -- Right, let's actually request some events, assuming we haven't already done so recently
  if not Shopkeeper.isScanning and ((Shopkeeper.acctSavedVariables.lastScan[firstGuildName] == nil) or (timeLimit > Shopkeeper.acctSavedVariables.lastScan[firstGuildName])) then
    Shopkeeper.isScanning = true
    local checkOlder = false

    -- If we have no event info for this guild, let's do a full scan
    if Shopkeeper.numEvents[firstGuildName] == nil or Shopkeeper.numEvents[firstGuildName] == 0 then checkOlder = true end
    Shopkeeper.requestTimestamp = GetTimeStamp()
    RequestGuildHistoryCategoryNewest(firstGuildID, GUILD_HISTORY_SALES)

    if checkOlder then
      zo_callLater(function() Shopkeeper:ScanOlder(1, doAlert) end, 1500)
    else
      zo_callLater(function() Shopkeeper:DoScan(1, false, doAlert) end, 1500)
    end
  end
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 guildNum > 0 then
    local firstGuildName = GetGuildName(1)
    if Shopkeeper.acctSavedVariables.lastScan[firstGuildName] == nil or timeLimit > Shopkeeper.acctSavedVariables.lastScan[firstGuildName] then
      CHAT_SYSTEM:AddMessage("[Shopkeeper] " .. GetString(SK_REFRESH_START))
      Shopkeeper:ScanStores(true)

    else
      CHAT_SYSTEM:AddMessage("[Shopkeeper] " .. GetString(SK_REFRESH_WAIT))
    end
  end
end

-- Handle the reset button - clear out the search and scan tables,
-- and set the time of the last scan to nil.  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.isScanning = false
  Shopkeeper.numEvents = {}
  CHAT_SYSTEM:AddMessage("[Shopkeeper] " .. GetString(SK_RESET_DONE))
  CHAT_SYSTEM:AddMessage("[Shopkeeper] " .. GetString(SK_REFRESH_START))
  Shopkeeper:ScanStores(true)
end

-- Set up the labels and tooltips from translation files and do a couple other UI
-- setup routines
function Shopkeeper:SetupShopkeeperWindow()
  local settingsToUse = Shopkeeper.savedVariables
  if Shopkeeper.acctSavedVariables.allSettingsAccount then settingsToUse = Shopkeeper.acctSavedVariables end
  -- 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)

  -- Stats dropdown choice box
  local shopkeeperStatsGuild = CreateControlFromVirtual("ShopkeeperStatsGuildChooser", ShopkeeperStatsWindow, "ShopkeeperStatsGuildDropdown")
  shopkeeperStatsGuild:SetDimensions(270,25)
  shopkeeperStatsGuild:SetAnchor(LEFT, ShopkeeperStatsWindowGuildChooserLabel, RIGHT, 5, 0)
  shopkeeperStatsGuild.m_comboBox:SetSortsItems(false)

  -- Set column headers and search label from translation
  ShopkeeperWindowBuyer:SetText(GetString(SK_BUYER_COLUMN))
  ShopkeeperWindowGuild:SetText(GetString(SK_GUILD_COLUMN))
  ShopkeeperWindowItemName:SetText(GetString(SK_ITEM_COLUMN))
  ShopkeeperWindowSellTime:SetText(GetString(SK_TIME_COLUMN))
  ShopkeeperMiniWindowGuild:SetText(GetString(SK_GUILD_COLUMN))
  ShopkeeperMiniWindowItemName:SetText(GetString(SK_ITEM_COLUMN))
  ShopkeeperMiniWindowSellTime:SetText(GetString(SK_TIME_COLUMN))

  if settingsToUse.showUnitPrice then
    ShopkeeperWindowPrice:SetText(GetString(SK_PRICE_EACH_COLUMN))
    ShopkeeperMiniWindowPrice:SetText(GetString(SK_PRICE_EACH_COLUMN))
  else
    ShopkeeperWindowPrice:SetText(GetString(SK_PRICE_COLUMN))
    ShopkeeperMiniWindowPrice:SetText(GetString(SK_PRICE_COLUMN))
  end

  -- Set second half of window title from translation
  ShopkeeperWindowTitle:SetText("Shopkeeper - " .. GetString(SK_YOUR_SALES_TITLE))
  ShopkeeperMiniWindowTitle:SetText("Shopkeeper - " .. GetString(SK_YOUR_SALES_TITLE))

  -- And set the stats window title and slider label from translation
  ShopkeeperStatsWindowTitle:SetText("Shopkeeper " .. GetString(SK_STATS_TITLE))
  ShopkeeperStatsWindowGuildChooserLabel:SetText(GetString(SK_GUILD_COLUMN) .. ": ")
  ShopkeeperStatsWindowSliderLabel:SetText(GetString(SK_STATS_DAYS))

  -- Set up some helpful tooltips for the Buyer, Item, Time, and Price column headers
  ShopkeeperWindowBuyer:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_BUYER_TOOLTIP)) end)

  ShopkeeperWindowItemName:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_ITEM_TOOLTIP)) end)

  ShopkeeperMiniWindowItemName:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_ITEM_TOOLTIP)) end)

  ShopkeeperWindowSellTime:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_SORT_TIME_TOOLTIP)) end)

  ShopkeeperMiniWindowSellTime:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_SORT_TIME_TOOLTIP)) end)

  ShopkeeperWindowPrice:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_SORT_PRICE_TOOLTIP)) end)

  ShopkeeperMiniWindowPrice:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_SORT_PRICE_TOOLTIP)) end)

  -- View switch button
  ShopkeeperSwitchViewButton:SetText(GetString(SK_VIEW_ALL_SALES))
  ShopkeeperMiniSwitchViewButton:SetText(GetString(SK_VIEW_ALL_SALES))

  -- Total / unit price switch button
  if settingsToUse.showUnitPrice then
    ShopkeeperPriceSwitchButton:SetText(GetString(SK_SHOW_TOTAL))
    ShopkeeperMiniPriceSwitchButton:SetText(GetString(SK_SHOW_TOTAL))
  else
    ShopkeeperPriceSwitchButton:SetText(GetString(SK_SHOW_UNIT))
    ShopkeeperMiniPriceSwitchButton:SetText(GetString(SK_SHOW_UNIT))
  end

  -- Refresh button
  ShopkeeperRefreshButton:SetText(GetString(SK_REFRESH_LABEL))
  ShopkeeperMiniRefreshButton:SetText(GetString(SK_REFRESH_LABEL))

  -- Reset button
  ShopkeeperResetButton:SetText(GetString(SK_RESET_LABEL))
  ShopkeeperMiniResetButton:SetText(GetString(SK_RESET_LABEL))

  -- 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 8 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

  -- Stats buttons
  ShopkeeperWindowStatsButton:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_STATS_TOOLTIP))
  end)
  ShopkeeperMiniWindowStatsButton:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_STATS_TOOLTIP))
  end)

  -- View size change buttons
  ShopkeeperWindowViewSizeButton:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_SIZE_TOOLTIP))
  end)
  ShopkeeperMiniWindowViewSizeButton:SetHandler("OnMouseEnter", function(self)
    ZO_Tooltips_ShowTextTooltip(self, TOP, GetString(SK_SIZE_TOOLTIP))
  end)

  -- Slider setup
  ShopkeeperWindow:SetHandler("OnMouseWheel", Shopkeeper.OnSliderMouseWheel)
  ShopkeeperWindowSlider:SetValue(0)
  ShopkeeperMiniWindow:SetHandler("OnMouseWheel", Shopkeeper.OnSliderMouseWheel)
  ShopkeeperMiniWindowSlider:SetValue(0)
  ShopkeeperStatsWindowSlider:SetValue(0)

  -- 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 = settingsToUse.windowFont
  Shopkeeper:UpdateFonts()
  Shopkeeper.DisplayRows()
end

-- Init function
function Shopkeeper:Initialize()
  -- SavedVar defaults
  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"] = {},
    ["allSettingsAccount"] = false,
    ["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",
  }

  -- Populate savedVariables and fill the ScanResults table from savedvars
  self.savedVariables = ZO_SavedVars:New("ShopkeeperSavedVars", 1, GetDisplayName(), Defaults)
  self.acctSavedVariables = ZO_SavedVars:NewAccountWide("ShopkeeperSavedVars", 1, GetDisplayName(), acctDefaults)
  self.ScanResults = Shopkeeper.acctSavedVariables.scanHistory

  -- Update the lastScan value, as it was previously a number in older versions
  -- (may get removed eventually)
  if type(self.acctSavedVariables.lastScan) == "number" then
    local guildNum = GetNumGuilds()
    local oldStamp = Shopkeeper.acctSavedVariables.lastScan
    self.acctSavedVariables.lastScan = {}
    for i = 1, guildNum do self.acctSavedVariables.lastScan[GetGuildName(GetGuildId(i))] = oldStamp end
  end

  -- Setup the options menu and main windows
  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.acctSavedVariables.allSettingsAccount then historyDepth = self.acctSavedVariables.historyDepth end
  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(GetDisplayName())
  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
  self.uiFragment = ZO_FadeSceneFragment:New(ShopkeeperWindow)
  self.miniUiFragment = ZO_FadeSceneFragment:New(ShopkeeperMiniWindow)

  local settingsToUse = Shopkeeper.savedVariables
  if Shopkeeper.acctSavedVariables.allSettingsAccount then settingsToUse = Shopkeeper.acctSavedVariables end
  if settingsToUse.openWithMail then
    if settingsToUse.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 settingsToUse.openWithStore then
    if settingsToUse.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 settingsToUse.openWithMail then
      ShopkeeperWindow:SetHidden(true)
      ShopkeeperMiniWindow:SetHidden(true)
    end
  end)
  EVENT_MANAGER:RegisterForEvent(Shopkeeper.name, EVENT_CLOSE_TRADING_HOUSE, function()
    if settingsToUse.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)

  -- 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
  if self.acctSavedVariables.allSettingsAccount then scanInterval = self.acctSavedVariables.scanFreq * 1000 end
  EVENT_MANAGER:RegisterForUpdate(Shopkeeper.name, scanInterval, function() Shopkeeper:ScanStores(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(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() Shopkeeper.ToggleShopkeeperWindow() end