local OPCODE=208
local window
local topButton
local state={recipes={},level=1,xp=0,nextXp=50,maxBatch=20,money=0}

local function captureKeyboard()
  if not window then return end
  window:grabKeyboard()
  window.onKeyDown = function(_, keyCode, keyboardModifiers)
    if keyboardModifiers == KeyboardNoModifier and keyCode == KeyEscape then hide() end
    return true
  end
end

local function releaseKeyboard()
  if window then window:ungrabKeyboard() end
end
local selectedCategory='Mana'
local selectedRecipe
local recipeRows={}
local categories={'Mana','Health','Protection','Utility','Catalysts'}
-- Compensate for fractional UI scaling against the raster frame. A constant
-- pitch accumulates rounding error, so lower rows are intentionally pulled up.
local categoryY={Mana=0,Health=58,Protection=115,Utility=171,Catalysts=226}
local ingredientRowHeights={30,28,28,27,27}
-- Raster rows are affected by fractional client scaling. Pull only the lower
-- rows up to keep their visual centers on the engraved horizontal frames.

local function send(action,data)
  local protocol=g_game.getProtocolGame()
  if protocol then protocol:sendExtendedOpcode(OPCODE,json.encode({action=action,data=data or {}})) end
end

local function amount()
  local value=tonumber(window.details.controls.amount:getText()) or 1
  return math.max(1,math.min(state.maxBatch or 20,math.floor(value)))
end

local function setAmount(value)
  window.details.controls.amount:setText(tostring(math.max(1,math.min(state.maxBatch or 20,value))))
end

local function rank(level)
  if level>=50 then return 'Grand Alchemist' end
  if level>=40 then return 'Abyssal Alchemist' end
  if level>=30 then return 'Master Alchemist' end
  if level>=20 then return 'Rift Alchemist' end
  if level>=10 then return 'Distiller' end
  return 'Apprentice'
end

local function maxCraftable(recipe)
  if not recipe then return 1 end
  local maximum=state.maxBatch or 20
  if recipe.gold and recipe.gold>0 then maximum=math.min(maximum,math.floor((state.money or 0)/recipe.gold)) end
  for _,material in ipairs(recipe.mats or {}) do maximum=math.min(maximum,math.floor(material.have/material.need)) end
  return math.max(1,maximum)
end

function brewSelected(maximum)
  if not selectedRecipe then return end
  local count=amount()
  if maximum then
    count=maxCraftable(selectedRecipe)
    setAmount(count)
  end
  send('brew',{id=selectedRecipe.id,amount=count})
end

local function rebuildIngredients()
  local list=window.details.ingredientList
  local scroll=window.details.ingredientScroll
  scroll:setValue(0)
  scroll:hide()
  list:destroyChildren()
  if not selectedRecipe then return end
  local multiplier=amount()
  for index,material in ipairs(selectedRecipe.mats or {}) do
    local row=g_ui.createWidget('AlchemyIngredientRow',list)
    row:setHeight(ingredientRowHeights[index] or 27)
    row.icon:setItemId(material.clientId)
    row.ingredientName:setText(material.name)
    row.amount:setText(string.format('%d / %d',material.have,material.need*multiplier))
    row.amount:setColor(material.have>=material.need*multiplier and '#72d68a' or '#e2766f')
  end
  local row=g_ui.createWidget('AlchemyIngredientRow',list)
  row:setHeight(ingredientRowHeights[#(selectedRecipe.mats or {})+1] or 27)
  row.icon:setItemId(state.moneyClientId or 0)
  row.icon:setItemCount(100)
  row.icon:setShowCount(false)
  row.ingredientName:setText('Gold')
  row.amount:setText(string.format('%d / %d',state.money or 0,(selectedRecipe.gold or 0)*multiplier))
  row.amount:setColor((state.money or 0)>=(selectedRecipe.gold or 0)*multiplier and '#72d68a' or '#e2766f')
  -- Five raster rows are available (four materials plus gold), so scrolling
  -- would only retain a stale pixel offset and move icons away from the art.
  scroll:setValue(0)
  scroll:hide()
end

local function selectRecipe(recipe)
  selectedRecipe=recipe
  window.details.productIcon:setItemId(recipe.output.clientId)
  window.details.productName:setText(recipe.name)
  window.details.rank:setText(rank(recipe.level)..'  |  Alchemy Level '..recipe.level)
  window.details.effect:setText('Effect: '..recipe.effect)
  window.details.cooldown:setText('Cooldown: '..recipe.cooldown:gsub('^[^:]+:%s*',''))
  for _,entry in ipairs(recipeRows) do
    local active=entry.recipe==recipe
    entry.widget:setChecked(active)
    entry.widget:setBorderWidth(0)
    entry.widget:setBorderColor('#00000000')
    entry.widget:setBackgroundColor('#00000000')
    entry.widget.highlight:setBackgroundColor('#00000000')
    entry.widget.highlight:setBorderWidth(0)
    entry.widget.marker:setVisible(active)
  end
  -- Keep the controls interactive so the server can explain why a recipe
  -- cannot be brewed (license, crucible, recipe or level) instead of silently
  -- swallowing the click in a disabled widget.
  window.details.controls.brew:setEnabled(not recipe.disabled)
  window.details.controls.brewMaximum:setEnabled(not recipe.disabled)
  rebuildIngredients()
end

local function rebuildRecipes()
  local list=window.recipesPanel.recipeList
  local scroll=window.recipesPanel.recipeScroll
  local firstRecipe
  local preferredRecipeId=selectedRecipe and selectedRecipe.id or nil
  local preferredRecipe
  scroll:setValue(0)
  scroll:hide()
  list:destroyChildren()
  recipeRows={}
  selectedRecipe=nil
  for _,recipe in ipairs(state.recipes or {}) do
    if recipe.category==selectedCategory then
      local row=g_ui.createWidget('AlchemyRecipeRow',list)
      row.icon:setItemId(recipe.output.clientId)
      row.recipeName:setText(recipe.name)
      local status=recipe.disabled and 'BALANCE TEST LOCKED' or (recipe.learned and ('Alchemy Level '..recipe.level) or 'Recipe not learned')
      row.recipeLevel:setText(status)
      row.recipeLevel:setColor(recipe.disabled and '#e2766f' or (recipe.learned and '#bba96f' or '#8a8a8a'))
      row.onClick=function()selectRecipe(recipe)end
      recipeRows[#recipeRows+1]={widget=row,recipe=recipe}
      if not firstRecipe then firstRecipe=recipe end
      if recipe.id==preferredRecipeId then preferredRecipe=recipe end
    end
  end
  if preferredRecipe or firstRecipe then selectRecipe(preferredRecipe or firstRecipe) end
  -- Every current category fits in the five engraved recipe slots. Resetting
  -- after rebuilding is important because ScrollablePanel preserves offsets.
  scroll:setValue(0)
  scroll:hide()
end

local function rebuildCategories()
  local panel=window.categories
  panel:destroyChildren()
  for _,category in ipairs(categories) do
    local button=g_ui.createWidget('AlchemyCategoryButton',panel)
    button:setMarginTop(categoryY[category] or 0)
    button:setText(category)
    for _,recipe in ipairs(state.recipes or {}) do
      if recipe.category==category and recipe.output and recipe.output.clientId then
        button.icon:setItemId(recipe.output.clientId)
        break
      end
    end
    button:setChecked(category==selectedCategory)
    local active=category==selectedCategory
    button:setBorderWidth(0)
    button:setBorderColor('#00000000')
    button:setBackgroundColor('#00000000')
    button.highlight:setBackgroundColor('#00000000')
    button.highlight:setBorderWidth(0)
    button.marker:setVisible(active)
    button:setColor(active and '#ffe6a0' or '#f7e7b0')
    button.onClick=function()selectedCategory=category;rebuildCategories();rebuildRecipes()end
  end
end

local function rebuild(message)
  window.notice:setText(message and message~='' and message or (state.atCrucible and 'Crucible ready.' or 'Use an Alchemist\'s Crucible to brew.'))
  window.notice:setColor(state.licensed and '#c9b98d' or '#e2766f')
  window.progressPanel.profession:setText('Rift Alchemy '..(state.level or 1))
  window.progressPanel.xp:setText(string.format('%d / %d XP',state.xp or 0,state.nextXp or 50))
  window.progressPanel.progress:setValue(math.min(state.xp or 0,state.nextXp or 50),0,state.nextXp or 50)
  if modules.game_skills and modules.game_skills.setReperaProfession then
    modules.game_skills.setReperaProfession('riftAlchemy',state.level or 1,state.xp or 0,state.nextXp or 50)
  end
  rebuildCategories();rebuildRecipes()
end

local function onOpcode(protocol,opcode,buffer)
  local ok,msg=pcall(json.decode,buffer)
  if not ok or type(msg)~='table' then return end
  if msg.action=='syncBegin' then
    state=msg.data or state
    state.recipes={}
  elseif msg.action=='syncRecipe' then
    local recipe=msg.data
    if recipe then recipe.mats={};state.recipes[#state.recipes+1]=recipe end
  elseif msg.action=='syncMaterial' then
    local data=msg.data
    if data then
      for _,recipe in ipairs(state.recipes) do
        if recipe.id==data.recipe then recipe.mats[#recipe.mats+1]=data.material;break end
      end
    end
  elseif msg.action=='syncEnd' then
    rebuild(msg.message)
    if state.atCrucible then show(false) end
  end
end

function init()
  ProtocolGame.registerExtendedOpcode(OPCODE,onOpcode)
  connect(g_game,{onGameStart=onGameStart,onGameEnd=onGameEnd})
  window=g_ui.displayUI('alchemy');window:hide()
  window.details.controls.minus:setText('-')
  window.details.controls.plus:setText('+')
  window.details.controls.minus:raise()
  window.details.controls.plus:raise()
  window.details.controls.minus.onClick=function()setAmount(amount()-1);rebuildIngredients()end
  window.details.controls.plus.onClick=function()setAmount(amount()+1);rebuildIngredients()end
  window.details.controls.amount.onTextChange=function()scheduleEvent(rebuildIngredients,10)end
  window.details.controls.brew.onClick=function()brewSelected(false)end
  window.details.controls.brewMaximum.onClick=function()brewSelected(true)end
  window.details.controls.brew:raise()
  window.details.controls.brewMaximum:raise()
  window.topClose.onClick=hide
  window.close.onClick=hide
  topButton=modules.client_topmenu.addRightGameButton('reperaAlchemyButton',tr('Rift Alchemy'),'/images/topbuttons/alchemy',toggle,false,7)
  topButton:setSize({width=20,height=20})
  if g_game.isOnline() then onGameStart() end
end

function terminate()
  disconnect(g_game,{onGameStart=onGameStart,onGameEnd=onGameEnd})
  ProtocolGame.unregisterExtendedOpcode(OPCODE)
  if topButton then topButton:destroy();topButton=nil end
  releaseKeyboard()
  if window then window:destroy();window=nil end
end

function onGameStart()scheduleEvent(function()send('init')end,700)end
function onGameEnd() hide() end
function show(requestSync)if not window then return end;window:show();window:raise();window:focus();captureKeyboard();if requestSync~=false then send('init')end end
function hide()releaseKeyboard();if window then window:hide()end end
function toggle()if not window then return end;if window:isVisible()then hide()else show(true)end end
