local Logging = require("logging")
local CACHE_FILE = "/.storage.cache"
local LOCK_FILE = "/.storage.lock"
local LOG_FILE_PATH = "latest.log"
local Logger = Logging.firstLoad(
  Logging.LogLevel.DEBUG,
  Logging.OUTPUTS.combine(
    Logging.OUTPUTS.file(LOG_FILE_PATH),
    Logging.OUTPUTS.stdout
  )
)

local Chest = require("storage.chest")
local ItemGroup = require("storage.itemgroup")
local Storage = require("storage")
local Text = require("gfx.text")
local List = require("gfx.list")
local Event = require("gfx.event")
local Padding = require("gfx.padding")
local Container = require("gfx.container")
local Element = require("gfx.element")
local Progress = require("gfx.progress")
local Children = require("gfx.prop.children")
local Orientation = require("gfx.prop.orientation")

local function lock()
  if fs.exists(LOCK_FILE) then
    return false
  end

  -- Create lock file
  return fs.open(LOCK_FILE, "w+").close()
end

local function unlock()
  if not fs.exists(LOCK_FILE) then
    return false
  end

  -- Delete lock file
  local result, reason = pcall(fs.delete, LOCK_FILE)
  if not result then
    Logger:error("ERROR: Lock file could not be deleted: " .. reason)
  end

  return result
end

local function isLocked()
  return fs.exists(LOCK_FILE)
end

local function saveState(fname, ctrl)
  local ser = ctrl:toSerializable()
  local file = fs.open(fname, "w+")
  file.write(textutils.serialize(ser))
  file.close()
  Logger:info("saved state to", fname)
end

local function loadState(fname, node)
  if not isLocked() then
    Logger:info("Not locked! Loading cache...")
    local file = fs.open(fname, "r")
    if file ~= nil then
        local ser = textutils.unserialize(file.readAll())
        file.close()
        return Storage:fromSerializable(ser)
    end
  end

  Logger:info("Controller must scan chests...")
  local nodeName = peripheral.getName(node)
  local storageChests = {peripheral.find("inventory")}
  for i,v in ipairs(storageChests) do
    if peripheral.getName(v) == nodeName then
      table.remove(storageChests, i)
      break
    end
  end
  local controller = Storage:fromPeripherals(Storage.assumeHomogeneous(storageChests))
  saveState(fname, controller)
  unlock()
  return controller
end

local function updatePageRender(state, pages)
  if state.nextPage ~= nil then
    state.changingPage = state.nextPage ~= state.currentPage
    state.currentPage = state.nextPage
    state.nextPage = nil

    state.monitor.clear()
    state._pageRender = pages[state.currentPage](state)
    os.queueEvent("dummy_event")
  else
    state.changingPage = false
  end
end

local function renderDefault(state, rootElement)
  rootElement:setDirty(true)
  rootElement:setParent(state.monitor)
  rootElement:_reload()
  return function()
    local event = {os.pullEvent()}
    local handled = rootElement:handleEvent(event)
    if not handled and event[1] == "monitor_resize" then
      local width, height = state.monitor.getSize()
      if state.width ~= width or state.height ~= height then
        state.width = width
        state.height = height

        -- Trigger re-render
        rootElement:setDirty(true)
        rootElement:_reload()
      end
    end

    if not handled and not Event.isClickEvent(event) then
      os.queueEvent(table.unpack(event))
    end

    rootElement:draw()
  end
end

local function NOOP() end

local function getCenterPad(contentWidth, widthBudget)
  if widthBudget < contentWidth then
    return 0, 0
  end

  local pad = (widthBudget - contentWidth) / 2
  return math.floor(pad), math.ceil(pad)
end

local function fitText(text, widthBudget)
  if #text <= widthBudget then
    return text
  end
  return text:sub(1, widthBudget)
end

local PAGES = {
  MAIN = function(state)
    local pageState = state:currentPageState({
      sortMode = 1,
      filter = "",
      displayKeyboard = false,
      currentPage = 1,
      reloadState = function() end
    })

    local function _genSort(func, invert, tiebreaker)
      return function(a, b)
        local aRes = func(a)
        local bRes = func(b)
        return (aRes == bRes and tiebreaker ~= nil and tiebreaker(a, b)) or ((aRes < bRes) ~= invert)
      end
    end

    local function sortByCount(invert, tiebreaker)
      return _genSort(function(v) return v:getItemCount() end, invert, tiebreaker)
    end

    local function sortByName(invert, tiebreaker)
      return _genSort(function(v) return v:getSimpleName() end, invert, tiebreaker)
    end

    local function sortByType(invert, tiebreaker)
      return _genSort(function(v) return v:getName() end, invert, tiebreaker)
    end

    local function sortByDamage(invert, tiebreaker)
      return _genSort(function(v)
        local damage = v:getDamage()
        return (damage == nil or damage == 0) and 1 or (damage / v:getMaxDamage())
      end, invert, tiebreaker)
    end

    local function composeSortFuncs(...)
      local funcs = {...}

      local invert = false
      local compose = nil
      for i=#funcs,1,-1 do
        local current = funcs[i]
        if type(current) == "boolean" then
          invert = current
          goto continue
        end
        compose = current(invert, compose)
        invert = false
          ::continue::
      end
      return compose
    end

    local SORT_MODE = {
      composeSortFuncs(sortByCount, true, sortByName, sortByType, sortByDamage),
      composeSortFuncs(sortByName, sortByType, sortByDamage, sortByCount),
      composeSortFuncs(sortByType, sortByDamage, sortByCount, sortByName),
      composeSortFuncs(sortByCount, sortByName, sortByType, sortByDamage),
      composeSortFuncs(sortByName, true, sortByType, sortByDamage, sortByCount),
      composeSortFuncs(sortByType, true, sortByDamage, sortByCount, sortByName)
    }


    local function matchFilter(filter, str)
      if #filter == 0 then
        return true
      end

      local cur = 1
      filter = filter:lower()
      str = str:lower()
      for i=1,#str do
        if str:sub(i,i) == filter:sub(cur,cur) then
          cur = cur + 1
          if cur > #filter then
            return true
          end
        end
      end
      return false
    end


    local storageSatProgress = Progress:new{
      width = state.width,
      height = 1
      -- Percentage of storage slots occupied
    }


    ---- BOTTOM BAR
    local keyboardButton = Text:new{
      id = "action_keyboard",
      text = "KBD"
    }

    local importButton = Text:new{
      id = "action_import",
      text = "IMPORT"
    }

    local sortButton = Text:new{
      id = "action_sort"
    }

    local function tabActionButtonID(value)
      return value < 0 and ("<"..tostring(-value)) or (tostring(value)..">")
    end

    local function tabActionButton(count)
      local text = tabActionButtonID(count)
      return Text:new{ id = text, text = text }
    end

    local function tabActionButtonValue(index, interval, count, sign)
      --             | s = 1: i - 1
      -- k(i, c, s) <|
      --             | s = -1: c - i

      -- Simple solution (7 operations):
      --               (c + 1)(1 - s) - 2
      -- k(i, c, s) =  ------------------ + i*s
      --                       2

      -- (`tabActionButtonID` abbreviated as `tabID`)
      -- tabID(i, t, c, s) = max(1, k(i, c, s) * t * s)

      -- E.g.
      -- interval = 5, count = 3, sign = 1
      -- i=1:  tabID(i, interval, count, sign) = max(1, k(1, 2, 1) * 5) * 1 = max(1, (-1 + 1) * 5) = max(1, 0) = 1
      -- i=2:  tabID(i, interval, count, sign) = max(1, k(2, 2, 1) * 5) * 1 = max(1, (-1 + 2) * 5) = max(1, 5) = 5
      -- i=3:  tabID(i, interval, count, sign) = max(1, k(3, 2, 1) * 5) * 1 = max(1, (-1 + 3) * 5) = max(1, 10) = 10

      -- E.g.
      -- interval = 5, count = 3, sign = -1
      -- i=1:  tabID(i, interval, count, sign) = max(1, k(1, 2, -1) * 5) * (-1) = -max(1, (3 + -1) * 5) = -max(1, 10) = -10
      -- i=2:  tabID(i, interval, count, sign) = max(1, k(2, 2, -1) * 5) * (-1) = -max(1, (3 + -2) * 5) = -max(1, 5) = -5
      -- i=3:  tabID(i, interval, count, sign) = max(1, k(3, 2, -1) * 5) * (-1) = -max(1, (3 + -3) * 5) = -max(1, 0) = -1
      return math.max(1, (((((count + 1) * (1 - sign)) - 2) / 2) + (sign * index)) * interval) * sign
    end

    local function tabActionList(interval, count, sign, spacing)
      local buttons = {}
      if count > 0 then
        table.insert(buttons, tabActionButton(tabActionButtonValue(1, interval, count, sign)))
        for i=2,count do
          table.insert(buttons, Element:new{ width = spacing })
          table.insert(buttons, tabActionButton(tabActionButtonValue(i, interval, count, sign)))
        end
      end

      return List:new{
        [Orientation:getId()] = Orientation.HORIZONTAL,
        [Children:getId()] = buttons
      }
    end

    local ACTION_COUNT = 2
    local ACTION_INTERVAL = 5
    local ACTION_SPACING = 1

    local actions = {
      tabActionList(ACTION_INTERVAL, ACTION_COUNT, -1, ACTION_SPACING),
      List:new {
        [Orientation:getId()] = Orientation.HORIZONTAL,
        [Children:getId()] = {
          keyboardButton,
          Element:new{ width = 1 },
          importButton,
          Element:new{ width = 1 },
          sortButton
        }
      },
      tabActionList(ACTION_INTERVAL, ACTION_COUNT, 1, ACTION_SPACING)
    }

    for i=#actions+1,1,-1 do
      table.insert(actions, i, Element:new{ width = 0 })
    end

    local bottomBarList = List:new {
      [Orientation:getId()] = Orientation.HORIZONTAL,
      [Children:getId()] = actions
    }

    local function calculateActionSpaces()
      if #actions == 1 then
        return -- NOP for single padding element
      end

      local usedSpace = 0
      for i=2,#actions,2 do
        usedSpace = usedSpace + actions[i]:getWidth()
      end

      local barWidth = state.width
      local freeSpace = barWidth - usedSpace
      local asymmetry = freeSpace % ((#actions + 1) / 2)
      local part = (freeSpace - asymmetry) / ((#actions + 1) / 2)
      for i=1,#actions,2 do
        actions[i]:setWidth((i <= asymmetry and 1 or 0) + part)
      end
      bottomBarList:_reload()
    end


    local aboveEntries = { storageSatProgress }
    local belowEntries = { bottomBarList }
    local entries = {}
    for _,v in ipairs(aboveEntries) do
      table.insert(entries, v)
    end

    local groupEntryListBudget = state.height
    for _,v in ipairs(aboveEntries) do
      groupEntryListBudget = groupEntryListBudget - v:getHeight()
    end
    for _,v in ipairs(belowEntries) do
      groupEntryListBudget = groupEntryListBudget - v:getHeight()
    end

    local GroupEntryID = {
      PADDING = "padding",
      NAME = "name",
      COUNT = "count"
    }

    for i=1,groupEntryListBudget do
      local bgColor = (i % 2 == 0) and colors.gray or colors.black
      table.insert(
        entries,
        List:new{
          id = tostring(i),
          bgColor = bgColor,
          [Orientation:getId()] = Orientation.HORIZONTAL,
          [Children:getId()] = {
            Padding:new{
              id = GroupEntryID.PADDING,
              bgColor = bgColor,
              element = Text:new{ id = GroupEntryID.NAME, bgColor = bgColor }
            },
            Text:new{
              id = GroupEntryID.COUNT,
              bgColor = bgColor
            }
          }
        }
    )
    end

    for _,v in ipairs(belowEntries) do
      table.insert(entries, v)
    end

    local mainList = List:new{
      [Orientation:getId()] = Orientation.VERTICAL,
      [Children:getId()] = entries
    }

    local KEY_BACKSPACE = "backspace"
    local ID_FILTER_DISPLAY = "display_filter"
    local KEYBOARD_BG_COLOR = colors.lightGray
    local KEYBOARD_KEY_COLOR = colors.gray
    local keyboardLines = {
      lines = {
        { backspace = true, "1234567890" },
        { "qwertyuiop" },
        { "asdfghjkl" },
        { "zxcvbnm_:" }
      },
      elements = { }
    }

    local function charInputKeyList(chars, backspace)
      local keys = { }
      for i=1,#chars do
        local key = chars:sub(i, i)
        -- ((not backspace) and i == #keys and 0) or 1
        table.insert(keys, Padding:new{ bgColor = KEYBOARD_BG_COLOR, right = 1, element = Text:new{
          id = key,
          text = key,
          bgColor = KEYBOARD_KEY_COLOR,
          onClick = function()
            pageState.filter = pageState.filter..key
            pageState.reloadState()
            return true
          end
        }})
      end
      if backspace then
        table.insert(keys, Text:new{
          id = KEY_BACKSPACE,
          text = "<--",
          bgColor = KEYBOARD_KEY_COLOR,
          onClick = function()
            pageState.filter = fitText(pageState.filter, math.max(0, #pageState.filter - 1))
            pageState.reloadState()
            return true
          end
        })
      end
      return List:new{
        bgColor = colors.cyan,
        [Orientation:getId()] = Orientation.HORIZONTAL,
        [Children:getId()] = keys
      }
    end

    local KEYBOARD_HPAD = 1
    local keyboardWidth = KEYBOARD_HPAD * 2
    for _,line in ipairs(keyboardLines.lines) do
      local keyLineList = charInputKeyList(line[1], line.backspace)
      keyboardWidth = math.max(keyboardWidth, keyLineList:getWidth())
      table.insert(keyboardLines.elements, keyLineList)
    end

    -- TODO: Pad elements properly

    table.insert(keyboardLines.elements, 1, Text:new{ id = ID_FILTER_DISPLAY })

    local keyboardList = Padding:new{ bgColor = KEYBOARD_BG_COLOR, left = KEYBOARD_HPAD, right = KEYBOARD_HPAD, element = List:new{
      bgColor = KEYBOARD_BG_COLOR,
      [Orientation:getId()] = Orientation.VERTICAL,
      [Children:getId()] = keyboardLines.elements
    }}

    local screenContainer = Container:new{
      [Children:getId()] = {
        mainList,
        keyboardList
      },
      x = 1,
      y = 1,
      strict = true
    }

    local function reloadState()
      screenContainer:setWidth(state.width)
      screenContainer:setHeight(state.height)

      -- Enumerate inventory stats
      local emptyCount = 0
      pageState.stacks = ItemGroup.collectStacks(state.controller:find(function(stack)
        if stack:isEmpty() then
          emptyCount = emptyCount + 1
          return false
        else
          return matchFilter(pageState.filter, stack:getSimpleName()) or matchFilter(pageState.filter, stack:getDisplayName()) or (pageState.filter:find(":") ~= nil and matchFilter(pageState.filter, stack:getName()))
        end
      end))
      table.sort(pageState.stacks, SORT_MODE[pageState.sortMode])

      local lastPage = #pageState.stacks % groupEntryListBudget

      pageState.pages = (#pageState.stacks - lastPage) / groupEntryListBudget + (lastPage == 0 and 0 or 1)


      -- Set dynamic states for elements
      sortButton:setText("<"..tostring(pageState.sortMode)..">")
      storageSatProgress:setProgress(1 - (emptyCount / #pageState.stacks))

      local basePageIndex = (pageState.currentPage - 1) * groupEntryListBudget
      for i=1,groupEntryListBudget do
        local group = pageState.stacks[basePageIndex + i]

        local isVisible = group ~= nil
        local listEntry = mainList:findById(tostring(i))
        listEntry:setVisible(isVisible)
        if isVisible then
          local nameText = listEntry:findById(GroupEntryID.NAME)
          local namePadding = listEntry:findById(GroupEntryID.PADDING)
          local countText = listEntry:findById(GroupEntryID.COUNT)

          countText:setText(tostring(group:getItemCount()))

          local nameTextBudget = state.width - countText:getWidth() - 1
          local name = fitText(group:getSimpleName(), nameTextBudget)
          local padding = state.width - #name - countText:getWidth()

          nameText:setText(name)
          namePadding:setPadding{ right = padding }
          listEntry:_reload()
          listEntry:setOnClick(function()
            state:setPage("GROUP_DETAIL", group)
            return true
          end)
        else
          listEntry:setOnClick(nil)
        end
      end

---@diagnostic disable-next-line: redefined-local
      local keyboardWidth = keyboardList:getWidth()
      keyboardList:setPos(math.floor((screenContainer:getWidth() - keyboardWidth) / 2), screenContainer:getHeight() - keyboardList:getHeight() - bottomBarList:getHeight())

      local filterDisplayedText = fitText(pageState.filter, keyboardList:getWidth() - 2)
      local filterText = keyboardList:findById(ID_FILTER_DISPLAY)
      filterText:setText(filterDisplayedText)
      filterText:setX(1)

      keyboardList:setVisible(pageState.displayKeyboard)

      calculateActionSpaces()
      screenContainer:setDirty(true)
    end

    local function bindTabActionButtons()
      local function onClickHandler(change)
        return function()
          local newValue = math.max(1, math.min(pageState.currentPage + change, pageState.pages))
          if newValue ~= pageState.currentPage then
            pageState.currentPage = newValue
            reloadState()
          end
          return true
        end
      end
      for i=1,ACTION_COUNT do
        local value = tabActionButtonValue(i, ACTION_INTERVAL, ACTION_COUNT, 1)
        bottomBarList:findById(tabActionButtonID(value)):setOnClick(onClickHandler(value))
      end
      for i=1,ACTION_COUNT do
        local value = tabActionButtonValue(i, ACTION_INTERVAL, ACTION_COUNT, -1)
        bottomBarList:findById(tabActionButtonID(value)):setOnClick(onClickHandler(value))
      end
    end
    bindTabActionButtons()

    keyboardButton:setOnClick(function()
      Logger:debug("Toggling keyboard...")
      pageState.displayKeyboard = not pageState.displayKeyboard

      reloadState()
      return true
    end)

    importButton:setOnClick(function()
      Logger:debug("Importing...")
      state.node:rescan()
      -- Safely handle transfers, priming computer for a full reset/rescan in case server stops mid-transaction
      state:itemTransaction(function()
        for _,nodeStack in ipairs(state.node:find(function(s) return not s:isEmpty() end)) do
          if not state.controller:insertStack(nodeStack) then
            Logger:warn("Couldn't find a free slot for: ", nodeStack:getSimpleName())
          end
        end
      end)

      reloadState()
      return true
    end)

    sortButton:setOnClick(function()
      Logger:debug("Reorganizing...")
      pageState.sortMode = (pageState.sortMode % #SORT_MODE) + 1
      reloadState()
      return true
    end)

    pageState.reloadState = reloadState

    reloadState()

    return renderDefault(state, screenContainer)
  end,

  GROUP_DETAIL = function(state)
    local paddingSide = 0
    local paddingTop = 0

    local group = state:getExtra()
    if group == nil then
      Logger:error("No group passed to GROUP_DETAIL")
      state:setPage("MAIN")
      return NOOP
    end

    local itemName = fitText(group:getSimpleName(), state.width - (paddingSide * 2))
    local itemLeftPad, itemRightPad = getCenterPad(#itemName, state.width - (paddingSide * 2))

    local paddedTitle = Padding:new{
      top = 1,
      left = itemLeftPad,
      right = itemRightPad,
      bottom = 1,
      element = Text:new{
        text = itemName,
        bgColor = colors.gray
      },
      bgColor = colors.gray
    }

    local simpleDetailsList = List:new{
      [Orientation:getId()] = Orientation.HORIZONTAL,
      [Children:getId()] = {
        Padding:new{
          top = 0,
          left = 1,
          right = 1,
          bottom = 0,
          bgColor = colors.red,
          element = List:new{
            bgColor = colors.red,
            [Orientation:getId()] = Orientation.VERTICAL,
            [Children:getId()] = {
              Text:new{ text = "Count", bgColor = colors.red },
              Text:new{ text = tostring(group:getItemCount()), bgColor = colors.red }
            }
          }
        },
        Padding:new{
          bgColor = colors.blue,
          top = 0,
          left = 1,
          right = 1,
          bottom = 0,
          element = List:new{
            bgColor = colors.blue,
            [Orientation:getId()] = Orientation.VERTICAL,
            [Children:getId()] = {
              Text:new{ text = "Slots", bgColor = colors.blue },
              Text:new{ text = tostring(group:getStackCount()), bgColor = colors.blue }
            }
          }
        }
      }
    }

    local damage = group:getDamage()
    local maxDamage = group:getMaxDamage()
    local damageBar = nil
    if damage ~= nil and maxDamage ~= nil then
      damageBar = Padding:new{
        left = 1,
        right = 1,
        element = Progress:new{
          progress = (damage == 0 and maxDamage or damage / maxDamage),
          width = state.width - 2,
          height = 1,
          fgColor = colors.green
        }
      }
    end

    local infoElements = List:new{
      [Children:getId()] = {
        paddedTitle,
        Padding:new{
          top = 0,
          bottom = 1,
          left = 0,
          right = 0,
          element = simpleDetailsList
        },
        damageBar
      },
      [Orientation:getId()] = Orientation.VERTICAL,
      width = state.width
    }

    local stuffContainer = Container:new{
      [Children:getId()] = {
        Padding:new{
          top = paddingTop,
          left = paddingSide,
          right = paddingSide,
          bottom = 0,
          element = infoElements,
        },
        Padding:new{
          x = 1,
          y = state.height - 2,
          top = 1,
          left = 1,
          right = 1,
          bottom = 1,
          bgColor = colors.gray,
          element = Text:new{
            text = "Request",
            bgColor = colors.gray
          },
          onClick = function(e, x, y, s)
            state:setPage("REQUEST", group)
            return true
          end
        }
      },
      width = state.width,
      height = state.height,
      parent = state.monitor,
      onClick = function(e, x, y, s)
        state:setPage("MAIN")
        return true
      end
    }

    return renderDefault(state, stuffContainer)
  end,

  REQUEST = function(state)
    local group = state:getExtra()
    if group == nil then
      Logger:error("No group passed to REQUEST")
      state:setPage("MAIN")
      return NOOP
    end

    local pageState = state:currentPageState(
      { request = 0 },
      state.changingPage
    )

    local itemName = fitText(group:getSimpleName(), state.width)
    local itemLeftPad, itemRightPad = getCenterPad(#itemName, state.width)

    local paddedTitle = Padding:new{
      top = 1,
      left = itemLeftPad,
      right = itemRightPad,
      bottom = 1,
      element = Text:new{
        text = itemName,
        bgColor = colors.gray
      },
      bgColor = colors.gray
    }

    local function makeRequestButton(increment)
      local text = increment > 0 and ("+"..tostring(increment)) or tostring(increment)
      return Text:new{ id = text, text = text }
    end

    local PADDING_RQC_H = 1
    local paddedRequestCount = Padding:new{
      top = 3,
      left = PADDING_RQC_H,
      right = PADDING_RQC_H,
      bottom = 0,
      element = List:new{
        [Orientation:getId()] = Orientation.VERTICAL,
        [Children:getId()] = {
          List:new{
            [Orientation:getId()] = Orientation.HORIZONTAL,
            [Children:getId()] = {
              Text:new{ text = "Request" },
              Padding:new{
                top = 0,
                bottom = 0,
                left = math.floor((state.width - (PADDING_RQC_H * 2) - #("Request") - #("Available")) / 2),
                right = math.ceil((state.width - (PADDING_RQC_H * 2) - #("Request") - #("Available")) / 2),
                element = Text:new{ text = "/" }
              },
              Text:new{ text = "Available" },
            }
          },
          List:new{
            id = "list_request_count",
            [Orientation:getId()] = Orientation.HORIZONTAL,
            [Children:getId()] = {
              Text:new{ id = "data_request", text = tostring(pageState.request) },
              Padding:new{
                id = "data_divider",
                top = 0,
                bottom = 0,
                element = Text:new{ text = "/" }
              },
              Text:new{ id = "data_available", text = tostring(group:getItemCount()) },
            }
          },
          Progress:new{
            id = "request_capacity",
            [Orientation:getId()] = Orientation.HORIZONTAL,
            width = state.width - (PADDING_RQC_H * 2),
            height = 1,
            progress = pageState.request / group:getItemCount()
          },
          Element:new{
            height = 1,
            width = 0
          },
          Padding:new{
            top = 0,
            bottom = 0,
            left = math.floor((state.width - 12)/2),
            right = math.ceil((state.width - 12)/2),
            element = List:new{
              [Orientation:getId()] = Orientation.HORIZONTAL,
              [Children:getId()] = {
                makeRequestButton(-5),
                Element:new{ width = 1 },
                makeRequestButton(-1),
                Element:new{ width = 2 },
                makeRequestButton(1),
                Element:new{ width = 1 },
                makeRequestButton(5),
              }
            }
          }
        }
      }
    }

    local function paddedButton(label, width, height, parentHeight, parentWidth, onClick, bgColor, x, y)
      local labelElement = Text:new{ text = label, bgColor = bgColor }
      return Padding:new{
        x = (parentWidth + (x or 1)) % parentWidth,
        y = (parentHeight + (y or 1)) % parentHeight,
        top = math.ceil((height - labelElement:getHeight()) / 2),
        bottom = math.floor((height - labelElement:getHeight()) / 2),
        left = math.ceil((width - labelElement:getWidth()) / 2),
        right = math.floor((width - labelElement:getWidth()) / 2),
        element = labelElement,
        bgColor = bgColor,
        onClick = onClick
      }
    end

    local stuffContainer = List:new{
      [Orientation:getId()] = Orientation.VERTICAL,
      [Children:getId()] = {
        paddedTitle,
        paddedRequestCount
      },
      onClick = function(e, x, y, s)
        state:setPage("GROUP_DETAIL", group)
        return true
      end
    }
    -- Anchored to (1,1)
    local windowWrapper = Container:new{
      [Children:getId()] = {
        stuffContainer,
        paddedButton(
          "Fetch",
          state.width / 2,
          3,
          state.height,
          state.width,
          function()
            Logger:debug("Exporting to access node")
            state.node:rescan()
            state:itemTransaction(function()
              group:transferTo(state.node, pageState.request)
            end)
            state:setPage("MAIN")
            return true
          end,
          colors.green,
          1,
          -2
        ),
        paddedButton(
          "Back",
          state.width / 2,
          3,
          state.height,
          state.width,
          function()
            state:setPage("GROUP_DETAIL", group)
            return true
          end,
          colors.red,
          math.ceil(state.width / 2) + 1,
          -2
        )
      }
    }


    -- Set up event management
    local function updateDisplayState()
      Logger:debug("updateDisplayState")
      local dataRequestText = paddedRequestCount:findById("data_request")
      local dataDividerPad = paddedRequestCount:findById("data_divider")
      local dataAvailableText = paddedRequestCount:findById("data_available")
      local requestCapProgress = paddedRequestCount:findById("request_capacity")
      local requestCountList = paddedRequestCount:findById("list_request_count")

      local requestText = tostring(pageState.request)
      local availableText = tostring(group:getItemCount())

      dataRequestText:setText(requestText)
      dataDividerPad:setPadding{
        left = math.ceil((state.width - 1 - (PADDING_RQC_H * 2) - #requestText - #availableText) / 2),
        right = math.floor((state.width - 1 - (PADDING_RQC_H * 2) - #requestText - #availableText) / 2)
      }
      dataAvailableText:setText(availableText)
      requestCapProgress:setProgress(pageState.request / group:getItemCount())

      requestCountList:adjustPositions()
      stuffContainer:setDirty(true)
    end

    local function bindRequestButton(increment)
      local id = increment > 0 and ("+"..tostring(increment)) or tostring(increment)
      paddedRequestCount:findById(id):setOnClick(function(e, x, y, s)
        local newValue = math.max(0, math.min(pageState.request + increment, group:getItemCount()))
        if pageState.request ~= newValue then
          pageState.request = newValue
          updateDisplayState()
        end
        return true
      end)
    end

    bindRequestButton(-5)
    bindRequestButton(-1)
    bindRequestButton(1)
    bindRequestButton(5)

    updateDisplayState()

    return renderDefault(state, windowWrapper)
  end
}


local accessNode = Chest:fromPeripheral("minecraft:trapped_chest", true)
---@diagnostic disable-next-line: need-check-nil
local controller = loadState(CACHE_FILE, accessNode:getInventory())
local monitor = peripheral.find("monitor")
local width, height = monitor.getSize()

local CONTROLLER_STATE = {
  controller = controller,
  node = accessNode,
  width = width,
  height = height,
  monitor = monitor,
  nextPage = "MAIN",
  exit = false,
  pageState = {}
}

function CONTROLLER_STATE:itemTransaction(transact)
  lock()
  transact()
  saveState(CACHE_FILE, self.controller)
  unlock()
end

function CONTROLLER_STATE:currentPageState(default, override)
  self.pageState[self.currentPage] = ((not override or ((type(override) == "function") and not override(self.pageState[self.currentPage]))) and self.pageState[self.currentPage]) or default
  return self.pageState[self.currentPage]
end

function CONTROLLER_STATE:setPage(page, extra)
  Logger:debug("new page", page, extra)
  self.nextPage = page
  self.extra = extra
end

function CONTROLLER_STATE:getExtra()
  return self.extra
end

function CONTROLLER_STATE:reloadPage()
  self.nextPage = self.currentPage
end

while not CONTROLLER_STATE.exit do
  updatePageRender(CONTROLLER_STATE, PAGES)
  CONTROLLER_STATE._pageRender()
end