if ... ~= "__flib__.dictionary" then
  return require("__flib__.dictionary")
end

local gui = require("__flib__.gui")
local mod_gui = require("__core__.lualib.mod-gui")
local table = require("__flib__.table")

--- @class flib.DictionaryStorage
--- @field init_ran boolean
--- @field raw table<string, flib.Dictionary>
--- @field raw_count integer
--- @field to_translate string[]
--- @field translated table<string, table<string, flib.TranslatedDictionary>?>
--- @field wip flib.DictionaryWipData?

--- @class flib.DictionaryWipData
--- @field dict string
--- @field dicts table<string, flib.TranslatedDictionary>
--- @field finished boolean
--- @field key string?
--- @field last_batch_end flib.DictionaryTranslationRequest?
--- @field language string
--- @field received_count integer
--- @field requests table<uint, flib.DictionaryTranslationRequest>
--- @field request_tick uint
--- @field translator LuaPlayer

--- Utilities for creating dictionaries of localised string translations.
--- ```lua
--- local flib_dictionary = require("__flib__.dictionary")
--- ```
--- @class flib_dictionary
local flib_dictionary = {}

local request_timeout_ticks = (60 * 5)

--- @param init_only boolean?
--- @return flib.DictionaryStorage
local function get_data(init_only)
  if not storage.__flib or not storage.__flib.dictionary then
    error("Dictionary module was not properly initialized - ensure that all lifecycle events are handled.")
  end
  local data = storage.__flib.dictionary
  if init_only and data.init_ran then
    error("Dictionaries cannot be modified after initialization.")
  end
  return data
end

--- @param language string
--- @return LuaPlayer?
local function get_translator(language)
  for _, player in pairs(game.players) do
    if player.connected and player.locale == language then
      return player
    end
  end
end

--- @param data flib.DictionaryStorage
local function update_gui(data)
  local wip = data.wip
  for _, player in pairs(game.players) do
    local frame_flow = mod_gui.get_frame_flow(player)
    local window = frame_flow.flib_translation_progress
    if wip then
      if not window then
        _, window = gui.add(frame_flow, {
          type = "frame",
          name = "flib_translation_progress",
          style = mod_gui.frame_style,
          direction = "vertical",
          {
            type = "label",
            style = "frame_title",
            caption = { "gui.flib-translating-dictionaries" },
            tooltip = { "gui.flib-translating-dictionaries-description" },
          },
          {
            type = "frame",
            name = "pane",
            style = "inside_shallow_frame_with_padding",
            --- @diagnostic disable-next-line: missing-fields
            style_mods = { top_padding = 8 },
            direction = "vertical",
          },
        })
      end
      local pane = window.pane --[[@as LuaGuiElement]]
      local mod_flow = pane[script.mod_name]
      if not mod_flow then
        _, mod_flow = gui.add(pane, {
          type = "flow",
          name = script.mod_name,
          style_mods = { vertical_align = "center", top_margin = 4, horizontal_spacing = 8 },
          {
            type = "label",
            style = "caption_label",
            --- @diagnostic disable-next-line: missing-fields
            style_mods = { minimal_width = 130 },
            caption = { "?", { "mod-name." .. script.mod_name }, script.mod_name },
            ignored_by_interaction = true,
          },
          { type = "empty-widget", style = "flib_horizontal_pusher" },
          { type = "label", name = "language", style = "bold_label", ignored_by_interaction = true },
          {
            type = "progressbar",
            name = "bar",
            --- @diagnostic disable-next-line: missing-fields
            style_mods = { top_margin = 1, width = 100 },
            ignored_by_interaction = true,
          },
          {
            type = "label",
            name = "percentage",
            style = "bold_label",
            --- @diagnostic disable-next-line: missing-fields
            style_mods = { width = 24, horizontal_align = "right" },
            ignored_by_interaction = true,
          },
        })
      end
      local progress = wip.received_count / data.raw_count
      mod_flow.language.caption = wip.language
      mod_flow.bar.value = progress --[[@as double]]
      mod_flow.percentage.caption = tostring(math.min(math.floor(progress * 100), 99)) .. "%"
      mod_flow.tooltip =
        { "", (wip.dict or { "gui.flib-finishing" }), "\n" .. wip.received_count .. " / " .. data.raw_count }
    else
      if window then
        local mod_flow = window.pane[script.mod_name]
        if mod_flow then
          mod_flow.destroy()
        end
        if #window.pane.children == 0 then
          window.destroy()
        end
      end
    end
  end
end

--- @param data flib.DictionaryStorage
--- @return boolean success
local function request_next_batch(data)
  local raw = data.raw
  local wip = data.wip --[[@as flib.DictionaryWipData]]
  if wip.finished then
    wip.last_batch_end = nil
    return false
  end
  wip.last_batch_end = { language = wip.language, dict = wip.dict, key = wip.key }
  local requests, strings = {}, {}
  for i = 1, game.is_multiplayer() and 5 or 50 do
    local string
    repeat
      wip.key, string = next(raw[wip.dict], wip.key)
      if not wip.key then
        wip.dict = next(raw, wip.dict)
        if not wip.dict then
          -- We are done!
          wip.finished = true
        end
      end
    until string or wip.finished
    if wip.finished then
      break
    end
    local request = { dict = wip.dict, key = wip.key }
    requests[i] = request
    strings[i] = string
  end

  if not requests[1] then
    return false -- Finished
  end

  local translator = wip.translator
  if not translator.valid or not translator.connected or translator.locale ~= wip.language then
    local new_translator = get_translator(wip.language)
    if new_translator then
      wip.translator = new_translator
    else
      -- Cancel this translation
      data.wip = nil
      return false
    end
  end

  local ids = wip.translator.request_translations(strings)
  if not ids then
    return false
  end
  for i = 1, #ids do
    wip.requests[ids[i]] = requests[i]
  end
  --- @diagnostic disable-next-line: missing-fields
  wip.request_tick = game.tick

  update_gui(data)

  return true
end

--- @param data flib.DictionaryStorage
local function handle_next_language(data)
  if not next(data.raw) then
    -- This can happen if handle_next_language is called during on_init or on_configuration_changed
    return
  end
  while not data.wip and #data.to_translate > 0 do
    local next_language = table.remove(data.to_translate, 1)
    if next_language then
      local translator = get_translator(next_language)
      if translator then
        -- Start translation
        local dicts = {}
        for name in pairs(data.raw) do
          dicts[name] = {}
        end
        --- @type flib.DictionaryWipData
        data.wip = {
          dict = next(data.raw),
          dicts = dicts,
          finished = false,
          --- @type string?
          key = nil,
          language = next_language,
          received_count = 0,
          --- @type table<uint, flib.DictionaryTranslationRequest>
          requests = {},
          request_tick = 0,
          translator = translator,
        }
        request_next_batch(data)
      end
    end
  end
end

-- Events

flib_dictionary.on_player_dictionaries_ready = script.generate_event_name()
--- Called when a player's dictionaries are ready to be used. Handling this event is not required.
--- @class flib.on_player_dictionaries_ready: EventData
--- @field player_index uint

-- Lifecycle handlers

function flib_dictionary.on_init()
  if not storage.__flib then
    storage.__flib = {}
  end
  --- @type flib.DictionaryStorage
  storage.__flib.dictionary = {
    init_ran = false,
    player_language_requests = {},
    player_languages = {},
    raw = {},
    raw_count = 0,
    to_translate = {},
    translated = {},
    wip = nil,
  }
  for player_index, player in pairs(game.players) do
    if player.connected then
      flib_dictionary.on_player_joined_game({
        name = defines.events.on_player_joined_game,
        tick = game.tick,
        --- @cast player_index uint
        player_index = player_index,
      })
    end
  end
end

flib_dictionary.on_configuration_changed = flib_dictionary.on_init

function flib_dictionary.on_tick()
  local data = get_data()
  if not data.init_ran then
    data.init_ran = true
  end

  handle_next_language(data)

  local wip = data.wip
  if not wip then
    return
  end

  if game.tick - wip.request_tick > request_timeout_ticks then
    local request = wip.last_batch_end
    if not request then
      -- TODO: Remove WIP because we actually finished somehow? This should never happen I think
      error("We're screwed")
    end
    wip.dict = request.dict
    wip.finished = false
    wip.key = request.key
    wip.requests = {}
    request_next_batch(data)
    update_gui(data)
  end
end

--- @param e EventData.on_string_translated
function flib_dictionary.on_string_translated(e)
  local data = get_data()
  local id = e.id

  handle_next_language(data)

  local wip = data.wip
  if not wip then
    return
  end

  local request = wip.requests[id]
  if request then
    wip.requests[id] = nil
    wip.received_count = wip.received_count + 1
    if e.translated then
      wip.dicts[request.dict][request.key] = e.result
    end
  end

  while wip and not next(wip.requests) and not request_next_batch(data) do
    if wip.finished then
      data.translated[wip.language] = wip.dicts
      data.wip = nil
      for player_index, player in pairs(game.players) do
        if player.locale == wip.language then
          script.raise_event(flib_dictionary.on_player_dictionaries_ready, { player_index = player_index })
        end
      end
    end
    handle_next_language(data)
    update_gui(data)
    wip = data.wip
  end
end

--- @param e EventData.on_player_joined_game
function flib_dictionary.on_player_joined_game(e)
  local player = game.get_player(e.player_index) --- @unwrap
  if not player then
    return
  end
  local language = player.locale
  local data = get_data()
  if data.translated[language] then
    script.raise_event(flib_dictionary.on_player_dictionaries_ready, { player_index = e.player_index })
    return
  elseif data.wip and data.wip.language == language then
    return
  elseif table.find(data.to_translate, language) then
    return
  end
  table.insert(data.to_translate, language)
  handle_next_language(data)
  update_gui(data)
end

flib_dictionary.on_player_locale_changed = flib_dictionary.on_player_joined_game

--- Handle all non-bootstrap events with default event handlers. Will not overwrite any existing handlers. If you have
--- custom handlers for on_tick, on_string_translated, or on_player_joined_game, ensure that you call the corresponding
--- module lifecycle handler..
function flib_dictionary.handle_events()
  for id, handler in pairs({
    [defines.events.on_tick] = flib_dictionary.on_tick,
    [defines.events.on_string_translated] = flib_dictionary.on_string_translated,
    [defines.events.on_player_joined_game] = flib_dictionary.on_player_joined_game,
  }) do
    if
      not script.get_event_handler(id --[[@as uint]])
    then
      script.on_event(id, handler)
    end
  end
end

--- For use with `__core__/lualib/event_handler`. Pass `flib_dictionary` into `handler.add_lib` to
--- handle all relevant events automatically.
flib_dictionary.events = {
  [defines.events.on_player_joined_game] = flib_dictionary.on_player_joined_game,
  [defines.events.on_player_locale_changed] = flib_dictionary.on_player_locale_changed,
  [defines.events.on_string_translated] = flib_dictionary.on_string_translated,
  [defines.events.on_tick] = flib_dictionary.on_tick,
}

-- Dictionary creation

--- Create a new dictionary. The name must be unique.
--- @param name string
--- @param initial_strings flib.Dictionary?
function flib_dictionary.new(name, initial_strings)
  local data = get_data(true)
  local raw = data.raw
  if raw[name] then
    error("Attempted to create dictionary '" .. name .. "' twice.")
  end
  raw[name] = initial_strings or {}
  if initial_strings then
    data.raw_count = data.raw_count + table_size(initial_strings)
  end
end

--- Add the given string to the dictionary.
--- @param dict_name string
--- @param key string
--- @param localised LocalisedString
function flib_dictionary.add(dict_name, key, localised)
  local data = get_data(true)
  local raw = data.raw[dict_name]
  if not raw then
    error("Dictionary '" .. dict_name .. "' does not exist.")
  end
  if not raw[key] then
    data.raw_count = data.raw_count + 1
  end
  raw[key] = localised
end

--- Get all dictionaries for the player. Will return `nil` if the player's language has not finished translating.
--- @param player_index uint
--- @return table<string, flib.TranslatedDictionary>?
function flib_dictionary.get_all(player_index)
  local player = game.get_player(player_index)
  if not player then
    return
  end
  return get_data().translated[player.locale]
end

--- Get the specified dictionary for the player. Will return `nil` if the dictionary has not finished translating.
--- @param player_index uint
--- @param dict_name string
--- @return flib.TranslatedDictionary?
function flib_dictionary.get(player_index, dict_name)
  local data = get_data()
  if not data.raw[dict_name] then
    error("Dictionary '" .. dict_name .. "' does not exist.")
  end
  local language_dicts = flib_dictionary.get_all(player_index) or {}
  return language_dicts[dict_name]
end

--- @class flib.DictionaryLanguageRequest
--- @field player LuaPlayer
--- @field tick uint

--- @class flib.DictionaryTranslationRequest
--- @field language string
--- @field dict string
--- @field key string

--- Localised strings identified by an internal key. Keys must be unique and language-agnostic.
--- @alias flib.Dictionary table<string, LocalisedString>

--- Translations are identified by their internal key. If the translation failed, then it will not be present. Locale
--- fallback groups can be used if every key needs a guaranteed translation.
--- @alias flib.TranslatedDictionary table<string, string>

return flib_dictionary
