
--functions

eye_spy = {}

local S = minetest.get_translator(minetest.get_current_modname())
local MCS = minetest.get_translator("mcl_doc")
local MCST = minetest.get_translator("mcl_tools")

local show_liquids = minetest.settings:get_bool("eye_spy.show_liquids") or false
local show_entities = minetest.settings:get_bool("eye_spy.show_entities") ~= false
local always_show_hand_in_creative = minetest.settings:get_bool("eye_spy.always_show_hand_in_creative") ~= false
local mod_security = minetest.settings:get("secure.enable_security") or false
local texture_pack_size = minetest.settings:get("eye_spy.texture_pack_size") or "None"
local texture_sizes = {["None"] = nil, ["1x1px"] = 1, ["2x2px"] = 2, ["4x4px"] = 4, ["8x8px"] = 8, ["16x16px"] = 16, ["32x32px"] = 32, ["64x64px"] = 64, ["128x128px"] = 128, ["256x256px"] = 256, ["512x512px"] = 512, ["1024x1024px"] = 1024}
local forced_texture_size = texture_sizes[texture_pack_size]
local texture_pack_path = minetest.settings:get("texture_path")

eye_spy.S = S
eye_spy.MCS = MCS
eye_spy.MCST = MCST

eye_spy.state = eye_spy.state or {players = {}}
eye_spy.cache = eye_spy.cache or {
  tool_info = {},
}
eye_spy.pending_preview_updates = eye_spy.pending_preview_updates or {}
eye_spy.pending_layout_refreshes = eye_spy.pending_layout_refreshes or {}
eye_spy.config = {
  show_liquids = show_liquids,
  show_entities = show_entities,
  always_show_hand_in_creative = always_show_hand_in_creative,
  mod_security = minetest.settings:get_bool("secure.enable_security") == true,
  texture_pack_size = texture_pack_size,
  forced_texture_size = forced_texture_size,
  texture_pack_path = texture_pack_path,
  update_interval = tonumber(minetest.settings:get("eye_spy.update_interval")) or 0.5,
  show_growth = minetest.settings:get_bool("eye_spy.show_growth") ~= false,
  show_icons = minetest.settings:get_bool("eye_spy.show_icons") ~= false,
  default_show_coords = minetest.settings:get_bool("eye_spy.default_show_coords") or false,
  default_show_light_level = minetest.settings:get_bool("eye_spy.default_show_light_level") ~= false,
  default_show_spawn_hint = minetest.settings:get_bool("eye_spy.default_show_spawn_hint") or false,
  default_show_liquid_info = minetest.settings:get_bool("eye_spy.default_show_liquid_info") ~= false,
  default_show_dig_time = minetest.settings:get_bool("eye_spy.default_show_dig_time") ~= false,
  perf_metrics_enabled = minetest.settings:get_bool("eye_spy.enable_perf_metrics") or false,
}

eye_spy.edit_mode = {}
eye_spy.storage = eye_spy.storage or minetest.get_mod_storage()
local PERF_SAMPLE_CAP = 200

local persisted_perf = eye_spy.storage:get_string("perf_metrics_enabled")
local perf_enabled = eye_spy.config.perf_metrics_enabled

if persisted_perf == "true" then
  perf_enabled = true
elseif persisted_perf == "false" then
  perf_enabled = false
end

eye_spy.perf = eye_spy.perf or {
  enabled = perf_enabled,
  metrics = {},
  counters = {},
}

eye_spy.perf.counters = eye_spy.perf.counters or {}

function eye_spy.perf_set_enabled(enabled)
  local value = enabled == true
  eye_spy.perf.enabled = value
  eye_spy.storage:set_string("perf_metrics_enabled", value and "true" or "false")
end

function eye_spy.perf_reset()
  eye_spy.perf.metrics = {}
  eye_spy.perf.counters = {}
end

function eye_spy.perf_inc_counter(counter_name, delta)
  if not eye_spy.perf.enabled then
    return
  end

  local key = tostring(counter_name or "")

  if key == "" then
    return
  end

  local step = tonumber(delta) or 1

  if step == 0 then
    return
  end

  local counters = eye_spy.perf.counters
  counters[key] = (counters[key] or 0) + step
end

function eye_spy.perf_record(metric_name, duration_us)
  if not eye_spy.perf.enabled then
    return
  end

  local us = tonumber(duration_us) or 0

  if us < 0 then
    return
  end

  local metrics = eye_spy.perf.metrics
  local metric = metrics[metric_name]

  if not metric then
    metric = {
      count = 0,
      total_us = 0,
      max_us = 0,
      samples = {},
      sample_index = 1,
      sample_filled = 0,
    }
    metrics[metric_name] = metric
  end

  metric.count = metric.count + 1
  metric.total_us = metric.total_us + us
  metric.max_us = math.max(metric.max_us, us)
  metric.samples[metric.sample_index] = us
  metric.sample_index = metric.sample_index + 1

  if metric.sample_index > PERF_SAMPLE_CAP then
    metric.sample_index = 1
  end

  if metric.sample_filled < PERF_SAMPLE_CAP then
    metric.sample_filled = metric.sample_filled + 1
  end
end

local function metric_p95(metric)
  local n = metric and metric.sample_filled or 0

  if n <= 0 then
    return 0
  end

  local values = {}

  for i = 1, n do
    values[i] = metric.samples[i] or 0
  end

  table.sort(values)
  local idx = math.max(1, math.ceil(n * 0.95))
  return values[idx] or 0
end

function eye_spy.perf_report()
  local keys = {}
  local counter_keys = {}

  for key, metric in pairs(eye_spy.perf.metrics) do
    if metric and metric.count > 0 then
      keys[#keys + 1] = key
    end
  end

  table.sort(keys)

  for key, value in pairs(eye_spy.perf.counters or {}) do
    if (tonumber(value) or 0) ~= 0 then
      counter_keys[#counter_keys + 1] = key
    end
  end

  table.sort(counter_keys)

  if #keys == 0 and #counter_keys == 0 then
    return "No Eye Spy perf samples yet"
  end

  local lines = {
    "Eye Spy perf (avg/p95/max in ms):",
  }

  for _, key in ipairs(keys) do
    local metric = eye_spy.perf.metrics[key]
    local avg_ms = (metric.total_us / math.max(metric.count, 1)) / 1000
    local p95_ms = metric_p95(metric) / 1000
    local max_ms = metric.max_us / 1000
    lines[#lines + 1] = string.format("%s: avg=%.3f p95=%.3f max=%.3f n=%d", key, avg_ms, p95_ms, max_ms, metric.count)
  end

  if #counter_keys > 0 then
    lines[#lines + 1] = "Eye Spy perf counters:"

    for _, key in ipairs(counter_keys) do
      lines[#lines + 1] = string.format("%s: n=%d", key, eye_spy.perf.counters[key] or 0)
    end
  end

  return table.concat(lines, "\n")
end

local function clamp_interval_ms(value)
  return math.max(50, math.min(5000, tonumber(value) or 250))
end

local function default_interval_for_mode()
  if minetest.is_singleplayer and minetest.is_singleplayer() then
    return 100
  end

  return 150
end

function eye_spy.get_server_default_interval_ms()
  local raw = eye_spy.storage:get_string("server_default_interval_ms")

  if raw and raw ~= "" then
    return clamp_interval_ms(raw)
  end

  return default_interval_for_mode()
end

function eye_spy.set_server_default_interval_ms(interval_ms)
  eye_spy.storage:set_string("server_default_interval_ms", tostring(clamp_interval_ms(interval_ms)))
end

function eye_spy.get_player_interval_override_ms(player_name)
  if not player_name or player_name == "" then
    return nil
  end

  local raw = eye_spy.storage:get_string("player_interval_ms:" .. player_name)

  if raw == "" then
    return nil
  end

  return clamp_interval_ms(raw)
end

function eye_spy.set_player_interval_override_ms(player_name, interval_ms)
  if not player_name or player_name == "" then
    return
  end

  local key = "player_interval_ms:" .. player_name

  if interval_ms == nil then
    eye_spy.storage:set_string(key, "")
    return
  end

  eye_spy.storage:set_string(key, tostring(clamp_interval_ms(interval_ms)))
end

function eye_spy.get_effective_interval_ms(player_name)
  return eye_spy.get_player_interval_override_ms(player_name) or eye_spy.get_server_default_interval_ms()
end

function eye_spy.apply_player_interval(player)
  if not player then
    return
  end

  local meta = player:get_meta()
  local player_name = player:get_player_name()
  meta:set_int("es_update_interval_ms", eye_spy.get_effective_interval_ms(player_name))
end

minetest.register_privilege("eye_spy_rate", {
  description = "Manage Eye Spy HUD refresh rates",
  give_to_singleplayer = true,
})

minetest.register_chatcommand("eye_spy_rate", {
  params = "show | default <ms> | player <name> <ms|default>",
  description = "Admin: view/set Eye Spy refresh rate defaults and optional per-player overrides",
  privs = {eye_spy_rate = true},
  func = function(name, param)
    local args = {}

    for token in (param or ""):gmatch("%S+") do
      args[#args + 1] = token
    end

    local action = args[1]

    if not action or action == "" or action == "show" then
      local server_default = eye_spy.get_server_default_interval_ms()
      local self_effective = eye_spy.get_effective_interval_ms(name)
      return true, string.format("Eye Spy rate: server default=%dms, your effective=%dms", server_default, self_effective)
    end

    if action == "default" then
      local ms = tonumber(args[2])

      if not ms then
        return false, "Usage: /eye_spy_rate default <ms>"
      end

      local clamped = clamp_interval_ms(ms)
      eye_spy.set_server_default_interval_ms(clamped)

      for _, player in ipairs(minetest.get_connected_players()) do
        local player_name = player:get_player_name()

        if not eye_spy.get_player_interval_override_ms(player_name) then
          eye_spy.apply_player_interval(player)
        end
      end

      return true, string.format("Eye Spy server default refresh set to %dms", clamped)
    end

    if action == "player" then
      local target_name = args[2]
      local value = args[3]

      if not target_name or not value then
        return false, "Usage: /eye_spy_rate player <name> <ms|default>"
      end

      if value == "default" then
        eye_spy.set_player_interval_override_ms(target_name, nil)
      else
        local ms = tonumber(value)

        if not ms then
          return false, "Usage: /eye_spy_rate player <name> <ms|default>"
        end

        eye_spy.set_player_interval_override_ms(target_name, clamp_interval_ms(ms))
      end

      local target_player = minetest.get_player_by_name(target_name)

      if target_player then
        eye_spy.apply_player_interval(target_player)
      end

      return true, string.format(
        "Eye Spy rate for %s is now %dms",
        target_name,
        eye_spy.get_effective_interval_ms(target_name)
      )
    end

    return false, "Usage: /eye_spy_rate show | default <ms> | player <name> <ms|default>"
  end,
})

minetest.register_chatcommand("eye_spy_perf", {
  params = "show | reset | on | off",
  description = "Admin: show/reset Eye Spy perf stats and enable/disable metrics",
  privs = {eye_spy_rate = true},
  func = function(_, param)
    local action = (param or ""):match("^%s*(%S+)") or "show"

    if action == "show" then
      return true, eye_spy.perf_report()
    end

    if action == "reset" then
      eye_spy.perf_reset()
      return true, "Eye Spy perf stats reset"
    end

    if action == "on" then
      eye_spy.perf_set_enabled(true)
      return true, "Eye Spy perf metrics enabled"
    end

    if action == "off" then
      eye_spy.perf_set_enabled(false)
      return true, "Eye Spy perf metrics disabled"
    end

    return false, "Usage: /eye_spy_perf show | reset | on | off"
  end,
})

function eye_spy.enter_edit_mode(player)
  if not player then
    return
  end

  local player_name = player:get_player_name()
  local state = eye_spy.get_player_state(player)

  if not state.edit_mode then
    eye_spy.ensure_player_defaults(player)
    state.edit_mode = true
    state.timer = 0
    eye_spy.edit_mode[player_name] = true

    if eye_spy.ui_drafts then
      eye_spy.ui_drafts[player_name] = nil
    end

    minetest.show_formspec(player_name, "eye_spy:ui", eye_spy.get_ui(player, {reopen = false}))
  end
end

function eye_spy.exit_edit_mode(player)
  if not player then
    return
  end

  local player_name = player:get_player_name()
  local state = eye_spy.get_player_state(player)

  if state.edit_mode then
    local meta = player:get_meta()
    meta:set_string("es_last_obj", "")
    meta:set_int("es_last_health", -1)

    state.edit_mode = false
    state.last_render_key = nil
    state.timer = 0

    eye_spy.edit_mode[player_name] = false
    eye_spy.pending_preview_updates[player_name] = nil

    if eye_spy.ui_drafts then
      eye_spy.ui_drafts[player_name] = nil
    end

    minetest.close_formspec(player_name, "eye_spy:ui")
    minetest.close_formspec(player_name, "eye_spy:layout_ui")

    if eye_spy.render and eye_spy.render.hide then
      eye_spy.render.hide(state, player)
    end

    if eye_spy.render and eye_spy.render.invalidate_layout_sig then
      eye_spy.render.invalidate_layout_sig(player_name)
    end
  end
end

function eye_spy.get_rgb(v)
  if not v or v == "" then
    return 26, 26, 27
  end

  v = v:gsub("%s", "")

  local r,g,b = v:match("^(%d+),(%d+),(%d+)$")
  r, g, b = tonumber(r), tonumber(g), tonumber(b)

  return r or 26, g or 26, b or 27
end

function eye_spy.rgb_to_hex(r, g, b)
  return string.format("%02X%02X%02X", r, g, b)
end

function eye_spy.ensure_player_defaults(player)
  local meta = player:get_meta()

  if meta:get_string("es_hud_color") == "" then
    meta:set_string("es_hud_color", "26, 26, 27")
  end

  if meta:get_string("es_hud_alignment") == "" then
    meta:set_string("es_hud_alignment", "Top-Middle")
  end

  if meta:get_string("es_hud_health_in") == "" then
    meta:set_string("es_hud_health_in", "Points")
  end

  if meta:get_string("es_auto_text_color") == "" then
    meta:set_string("es_auto_text_color", "true")
  end

  if meta:get_string("es_title_color_val") == "" then
    meta:set_int("es_title_color_val", 255)
  end

  if meta:get_string("es_subtitle_color_val") == "" then
    meta:set_int("es_subtitle_color_val", 255)
  end

  if meta:get_string("es_footer_color_val") == "" then
    meta:set_int("es_footer_color_val", 255)
  end

  if meta:get_string("es_line_color_val") == "" then
    meta:set_int("es_line_color_val", 255)
  end

  if meta:get_string("es_growth_color_val") == "" then
    meta:set_int("es_growth_color_val", 110)
  end

  if meta:get_string("es_soil_color_val") == "" then
    meta:set_int("es_soil_color_val", 165)
  end

  if meta:get_string("es_show_growth") == "" then
    meta:set_string("es_show_growth", eye_spy.config.show_growth and "true" or "false")
  end

  if meta:get_string("es_show_icons") == "" then
    meta:set_string("es_show_icons", eye_spy.config.show_icons and "true" or "false")
  end

  if meta:get_string("es_layout_target") == "" then
    meta:set_string("es_layout_target", "Global")
  end

  if meta:get_string("es_layout_step") == "" then
    meta:set_int("es_layout_step", 2)
  end

  if meta:get_string("es_layout_page") == "" then
    meta:set_string("es_layout_page", "Position")
  end

  if meta:get_string("es_footer_mode") == "" then
    meta:set_string("es_footer_mode", "compact")
  end

  if meta:get_string("es_show_coords") == "" then
    meta:set_string("es_show_coords", eye_spy.config.default_show_coords and "true" or "false")
  end

  if meta:get_string("es_show_light_level") == "" then
    meta:set_string("es_show_light_level", eye_spy.config.default_show_light_level and "true" or "false")
  end

  if meta:get_string("es_show_spawn_hint") == "" then
    meta:set_string("es_show_spawn_hint", eye_spy.config.default_show_spawn_hint and "true" or "false")
  end

  if meta:get_string("es_show_liquid_info") == "" then
    meta:set_string("es_show_liquid_info", eye_spy.config.default_show_liquid_info and "true" or "false")
  end

  if meta:get_string("es_show_dig_time") == "" then
    meta:set_string("es_show_dig_time", eye_spy.config.default_show_dig_time and "true" or "false")
  end

  if meta:get_string("es_spawn_safe_light_threshold") == "" then
    meta:set_int("es_spawn_safe_light_threshold", 8)
  end

  eye_spy.apply_player_interval(player)

  local int_defaults = {
    es_global_offset_x = 0,
    es_global_offset_y = 0,
    es_bg_offset_x = 0,
    es_bg_offset_y = 0,
    es_icon_offset_x = 0,
    es_icon_offset_y = 0,
    es_title_offset_x = 0,
    es_title_offset_y = 0,
    es_subtitle_offset_x = 0,
    es_subtitle_offset_y = 0,
    es_lines_offset_x = 0,
    es_lines_offset_y = 0,
    es_footer_offset_x = 0,
    es_footer_offset_y = 0,
    es_bg_pad_left = 7,
    es_bg_pad_right = 0,
    es_bg_pad_top = 5,
    es_bg_pad_bottom = 0,
    es_bg_extra_w = 0,
    es_bg_extra_h = 0,
    es_bg_scale_x_pct = 100,
    es_bg_scale_y_pct = 100,
    es_icon_size = 56,
    es_title_scale_pct = 100,
    es_subtitle_scale_pct = 100,
    es_line_scale_pct = 100,
    es_footer_scale_pct = 100,
    es_first_line_y_adj = 0,
    es_line_step_adj = 0,
    es_footer_nudge_adj = 0,
    es_text_base_x_adj = 0,
    es_icon_base_x_adj = 0,
    es_top_margin_adj = 0,
    es_bottom_margin_adj = 0,
  }

  for key, value in pairs(int_defaults) do
    if meta:get_string(key) == "" then
      meta:set_int(key, value)
    end
  end
end

function eye_spy.get_player_state(player)
  local player_name = player:get_player_name()
  local state = eye_spy.state.players[player_name]

  if not state then
    state = {
      timer = 0,
      edit_mode = false,
      last_target_key = nil,
      last_render_key = nil,
      last_geom_key = nil,
      last_probe_pos = nil,
      last_probe_dir = nil,
      cached_target = nil,
      skip_static_acquire = false,
      hud_hidden_since_us = nil,
      last_recover_try_us = 0,
      hud_ids = {
        lines = {},
      },
    }

    eye_spy.state.players[player_name] = state
  end

  return state
end

function eye_spy.invalidate_player(player)
  local player_name = player:get_player_name()
  local state = eye_spy.get_player_state(player)
  state.last_target_key = nil
  state.last_render_key = nil
  state.last_geom_key = nil
  state.cached_target = nil
  state.last_probe_pos = nil
  state.last_probe_dir = nil
  state.skip_static_acquire = false
  state.hud_hidden_since_us = nil
  state.last_recover_try_us = 0

  if eye_spy.render and eye_spy.render.invalidate_layout_sig then
    eye_spy.render.invalidate_layout_sig(player_name)
  end
end

local function probe_state_matches(state, player)
  if not state.last_probe_pos or not state.last_probe_dir then
    return false
  end

  local pos = player:get_pos()
  local dir = player:get_look_dir()
  local dx = math.abs((pos.x or 0) - (state.last_probe_pos.x or 0))
  local dy = math.abs((pos.y or 0) - (state.last_probe_pos.y or 0))
  local dz = math.abs((pos.z or 0) - (state.last_probe_pos.z or 0))
  local ddx = math.abs((dir.x or 0) - (state.last_probe_dir.x or 0))
  local ddy = math.abs((dir.y or 0) - (state.last_probe_dir.y or 0))
  local ddz = math.abs((dir.z or 0) - (state.last_probe_dir.z or 0))

  return dx < 0.05 and dy < 0.05 and dz < 0.05 and ddx < 0.01 and ddy < 0.01 and ddz < 0.01
end

local function remember_probe_state(state, player, target)
  local pos = player:get_pos()
  local dir = player:get_look_dir()

  state.last_probe_pos = vector.new(pos)
  state.last_probe_dir = vector.new(dir)
  state.cached_target = target
end

function eye_spy.update_player(player, opts)
  if not player or not eye_spy.target or not eye_spy.render then
    return
  end

  local perf_on = eye_spy.perf.enabled and minetest.get_us_time
  local update_start_us = perf_on and minetest.get_us_time() or 0
  local options = opts or {}
  local state = eye_spy.get_player_state(player)
  local target

  if not options.force and not options.preview and state.cached_target and probe_state_matches(state, player) and state.skip_static_acquire then
    target = state.cached_target
    state.skip_static_acquire = false
  else
    local acquire_start_us = perf_on and minetest.get_us_time() or 0
    target = eye_spy.target.acquire(player, options)
    if perf_on then
      eye_spy.perf_record("target_acquire", minetest.get_us_time() - acquire_start_us)
    end

    if not options.preview then
      remember_probe_state(state, player, target)
      state.skip_static_acquire = target and target.kind ~= "air"
    end
  end

  if not target or target.kind == "air" then
    local now_us = minetest.get_us_time and minetest.get_us_time() or 0

    eye_spy.render.hide(state, player)
    state.last_target_key = nil
    state.last_render_key = nil
    state.cached_target = nil
    state.skip_static_acquire = false
    if now_us > 0 and not state.hud_hidden_since_us then
      state.hud_hidden_since_us = now_us
    end
    if perf_on then
      eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
    end
    return
  end

  local build_start_us = perf_on and minetest.get_us_time() or 0
  local view_model = eye_spy.render.build_view_model(player, target, options)
  if perf_on then
    eye_spy.perf_record("build_view_model", minetest.get_us_time() - build_start_us)
  end

  if not options.force and view_model.key == state.last_render_key then
    state.last_target_key = target.key
    eye_spy.perf_inc_counter("render_skip_same_key")
    if perf_on then
      eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
    end
    return
  end

  local render_start_us = perf_on and minetest.get_us_time() or 0
  eye_spy.render.apply(state, player, view_model)
  if perf_on then
    eye_spy.perf_record("render_apply", minetest.get_us_time() - render_start_us)
  end

  state.last_target_key = target.key
  state.last_render_key = view_model.key
  state.hud_hidden_since_us = nil
  state.last_recover_try_us = 0

  if perf_on then
    eye_spy.perf_record("update_player_total", minetest.get_us_time() - update_start_us)
  end
end

function eye_spy.step(dtime)
  local perf_on = eye_spy.perf.enabled and minetest.get_us_time
  local step_start_us = perf_on and minetest.get_us_time() or 0
  local now_us = minetest.get_us_time and minetest.get_us_time() or 0

  for _, player in ipairs(minetest.get_connected_players()) do
    local state = eye_spy.get_player_state(player)
    local player_name = player:get_player_name()
    local pending_preview = eye_spy.pending_preview_updates[player_name]

    if state.edit_mode and pending_preview then
      local due_at = pending_preview.due_at_us or 0

      if pending_preview.force or due_at <= now_us then
        eye_spy.pending_preview_updates[player_name] = nil
        eye_spy.update_player(player, {
          preview = true,
          preview_type = pending_preview.preview_type,
          force = pending_preview.force == true,
        })
      end
    end

    local pending_layout_refresh = eye_spy.pending_layout_refreshes[player_name]

    if state.edit_mode and pending_layout_refresh and pending_layout_refresh.due_at_us <= now_us then
      eye_spy.pending_layout_refreshes[player_name] = nil
      minetest.show_formspec(player_name, "eye_spy:layout_ui", eye_spy.get_layout_ui(player))
    end

    if not state.edit_mode then
      local meta = player:get_meta()
      local interval_ms = meta:get_int("es_update_interval_ms")
      local interval = eye_spy.get_server_default_interval_ms() / 1000

      if interval_ms and interval_ms > 0 then
        interval = interval_ms / 1000
      end

      interval = math.max(0.05, math.min(5, interval))
      state.timer = state.timer + math.min(dtime, interval)

      if state.timer >= interval then
        state.timer = 0
        eye_spy.update_player(player, {preview = false})
      end

      -- Focus-loss/self-heal: if HUD appears stuck hidden while we still have a
      -- valid cached target, force an occasional refresh attempt.
      if state.hud_hidden_since_us and state.cached_target and state.cached_target.kind ~= "air" then
        local hidden_for_us = now_us - state.hud_hidden_since_us
        local recover_cooldown_us = 900000

        if hidden_for_us >= 1200000 and (state.last_recover_try_us or 0) + recover_cooldown_us <= now_us then
          state.last_recover_try_us = now_us
          eye_spy.perf_inc_counter("hud_recover_forced_refresh")
          eye_spy.update_player(player, {preview = false, force = true})
        end
      end
    end
  end

  if perf_on then
    eye_spy.perf_record("step_total", minetest.get_us_time() - step_start_us)
  end
end

function eye_spy.get_player_range(player)
  local wield = player:get_wielded_item()
  local def = wield:get_definition()

  if def.range then
    return def.range
  end

  if minetest.is_creative_enabled(player:get_player_name()) then
    return 10
  end

  return 4
end

-- Following tool/helper functions are older generated helpers kept for the active tool-info and render pipeline.

------------------------------------------------------------
-- Prüfen, ob ein Tool einen Node abbauen kann
------------------------------------------------------------
function eye_spy.tool_can_dig_node(toolstack, nodename)
    local tool_caps = toolstack:get_tool_capabilities()
    local ndef = minetest.registered_nodes[nodename]
    if not ndef then
      return false
    end

    local node_groups = ndef.groups or {}
    local dig_params = minetest.get_dig_params(node_groups, tool_caps)
    return dig_params.diggable, dig_params.time
end

-- Entfernt alle Einträge aus 'source', deren name eines der Wörter aus 'blocked' enthält.
function eye_spy.filter_out_by_name(source, blocked)
    local result = {}

    for _, entry in ipairs(source) do
        local name = entry.name or ""
        local keep = true

        for _, word in ipairs(blocked) do
            if string.find(name, word, 1, true) then
                keep = false
                break
            end
        end

        if keep then
            table.insert(result, entry)
        end
    end

    return result
end

------------------------------------------------------------
-- Tool: maxlevel extrahieren
------------------------------------------------------------
function eye_spy.get_tool_maxlevel(itemdef)
    local maxlevel = 0
    if not itemdef.tool_capabilities then return 0 end

    for _, gc in pairs(itemdef.tool_capabilities.groupcaps or {}) do
        if gc.maxlevel and gc.maxlevel > maxlevel then
            maxlevel = gc.maxlevel
        end
    end

    return maxlevel
end


------------------------------------------------------------
-- Mapping: Gruppe → Werkzeugtyp
------------------------------------------------------------
local GROUP_TO_TOOL_MTG = {
    cracky = S("Pickaxe"),
    crumbly = S("Shovel"),
    choppy = S("Axe"),
    snappy = S("Sword"),
    dig_immediate = S("Hand")
}

local GROUP_ORDER_MTG = {"dig_immediate", "snappy", "crumbly", "choppy", "cracky"}

local GROUP_TO_TOOL_MC = {
    swordy = S("Sword"),
    handy = S("Hand"),
    pickaxey = S("Pickaxe"),
    shovely = S("Shovel"),
    axey = S("Axe"),
    shearsy = S("Shears"),
    dig_immediate = S("Hand")
}

local GROUP_ORDER_MC = {"dig_immediate", "handy", "swordy", "shearsy", "shovely", "axey", "pickaxey"}

local function clone_list(values)
  local result = {}

  for index, value in ipairs(values or {}) do
    result[index] = value
  end

  return result
end

local function append_unique(list, value)
  if not value or value == "" then
    return
  end

  for _, existing in ipairs(list) do
    if existing == value then
      return
    end
  end

  list[#list + 1] = value
end

local function get_tool_mapping(game_id)
  if game_id == "mineclone" then
    return GROUP_TO_TOOL_MC, GROUP_ORDER_MC
  end

  return GROUP_TO_TOOL_MTG, GROUP_ORDER_MTG
end

local function collect_tool_types(node_groups, group_to_tool, group_order)
  local result = {}

  for _, group in ipairs(group_order) do
    if node_groups[group] and group_to_tool[group] then
      append_unique(result, group_to_tool[group])
    end
  end

  return result
end

local function get_preferred_tool_group(node_groups, group_to_tool, group_order)
  for _, group in ipairs(group_order) do
    if node_groups[group] and group_to_tool[group] then
      return group, group_to_tool[group]
    end
  end

  return nil, S("None")
end

local function format_tool_summary(minimum_types, best_type)
  local minimum_label = table.concat(minimum_types or {}, " / ")

  if best_type and best_type ~= "" and best_type ~= S("None") and best_type ~= S("Unknown") then
    if minimum_label == "" then
      return best_type
    end

    if #minimum_types == 1 and minimum_types[1] == best_type then
      return best_type
    end

    for _, minimum_type in ipairs(minimum_types or {}) do
      if minimum_type == best_type then
        return minimum_label
      end
    end

    return minimum_label .. " | " .. best_type
  end

  if minimum_label ~= "" then
    return minimum_label
  end

  return S("Unknown")
end

local function tool_matches_group(itemdef, preferred_group)
  if not preferred_group or preferred_group == "dig_immediate" then
    return false
  end

  local caps = itemdef and itemdef.tool_capabilities
  local groupcaps = caps and caps.groupcaps

  return groupcaps and groupcaps[preferred_group] ~= nil or false
end

function eye_spy.get_node_tool_hint_and_match(node_name, toolstack)
  if not node_name or node_name == "" then
    return nil, false
  end

  local def = minetest.registered_nodes[node_name]

  if not def or not def.groups then
    return nil, false
  end

  local node_groups = def.groups
  local group_to_tool, group_order = get_tool_mapping(cg)
  local minimum_types = collect_tool_types(node_groups, group_to_tool, group_order)
  local preferred_group, tooltype = get_preferred_tool_group(node_groups, group_to_tool, group_order)
  local summary = format_tool_summary(minimum_types, tooltype)

  if summary == S("Unknown") or summary == S("None") then
    summary = nil
  end

  -- Nodes with no preferred dig group (or dig_immediate) are considered
  -- compatible with any held item for colour-state purposes.
  if not preferred_group or preferred_group == "dig_immediate" then
    return summary, true
  end

  local itemdef = toolstack and toolstack.get_definition and toolstack:get_definition() or nil
  local caps = itemdef and itemdef.tool_capabilities or nil
  local groupcaps = caps and caps.groupcaps or nil
  local matches = groupcaps and groupcaps[preferred_group] ~= nil or false

  return summary, matches
end


------------------------------------------------------------
-- Hauptfunktion: empfohlenes Tool + Werkzeugtyp
------------------------------------------------------------
function eye_spy.get_min_required_tool(node_name, player)
    local def = minetest.registered_nodes[node_name]
    if not def or not def.groups then
        return
    end

    local node_groups = def.groups
    local cache_key = (cg or "unknown")..":"..node_name
    local hand_can = eye_spy.tool_can_dig_node(ItemStack(""), node_name)


    ------------------------------------------------------------
    -- 1) Werkzeugtyp bestimmen (unabhängig von Hand)
    ------------------------------------------------------------
    local tooltype = S("None") -- fallback
    local preferred_group
    local group_to_tool, group_order = get_tool_mapping(cg)
    local minimum_types = collect_tool_types(node_groups, group_to_tool, group_order)

    preferred_group, tooltype = get_preferred_tool_group(node_groups, group_to_tool, group_order)

    if hand_can then
      append_unique(minimum_types, S("Hand"))
    end

    if always_show_hand_in_creative then
      if minetest.is_creative_enabled(player:get_player_name()) then
        if not node_groups.unbreakable then
          return {
            name = "hand",
            description = format_tool_summary(minimum_types, tooltype),
            type = tooltype,
            best_type = tooltype,
            minimum_types = clone_list(minimum_types),
          }
        else
          return {
            name = "hand",
            description = S("Hand"),
            type = S("None"),
            best_type = S("None"),
            minimum_types = {S("Hand")},
          }
        end
      end
    end

    if eye_spy.cache.tool_info[cache_key] then
      local cached = eye_spy.cache.tool_info[cache_key]
      return {
        name = cached.name,
        description = cached.description,
        type = cached.type,
        best_type = cached.best_type,
        minimum_types = clone_list(cached.minimum_types),
      }
    end

    if cg == "minetest_game" then
        if preferred_group == "dig_immediate" and hand_can then
          return {
            name = "hand",
            description = format_tool_summary(minimum_types, S("Hand")),
            type = S("Hand"),
            best_type = S("Hand"),
            minimum_types = clone_list(minimum_types),
          }
        end

        local tools = {}

        for toolname, itemdef in pairs(minetest.registered_items) do
            if itemdef.tool_capabilities then
                local stack = ItemStack(toolname)
                local can, time = eye_spy.tool_can_dig_node(stack, node_name)

                if can then
                    local maxlevel = eye_spy.get_tool_maxlevel(itemdef)

                    table.insert(tools, {
                        name = toolname,
                        display = toolname,
                        maxlevel = maxlevel,
                        matches_group = tool_matches_group(itemdef, preferred_group),
                        time = time or 999,
                        itemdef = itemdef,
                    })
                end
            end
        end

        tools = eye_spy.filter_out_by_name(tools, {"hand", "enchanted", "spear", "hammer", "shepherd"})

        if #tools == 0 then
          if hand_can then
            local result = {
              name = "hand",
              description = format_tool_summary(minimum_types, S("Hand")),
              type = S("Hand"),
              best_type = S("Hand"),
              minimum_types = clone_list(minimum_types),
            }
            eye_spy.cache.tool_info[cache_key] = result
            return result
          end

          local result = {
            name = "unbreakable",
            description = S("Unbreakable"),
            type = S("None"),
            best_type = S("None"),
            minimum_types = clone_list(minimum_types),
          }
          eye_spy.cache.tool_info[cache_key] = result
          return result
        end

        ------------------------------------------------------------
        -- 4) Beste Werkzeugklasse bestimmen
        ------------------------------------------------------------
        local best_candidates = {}

        for _, tool in ipairs(tools) do
          for _, group in ipairs(group_order) do
            if tool_matches_group(tool.itemdef, group) then
              best_candidates[#best_candidates + 1] = {
                type = group_to_tool[group],
                matches_group = group == preferred_group,
                time = tool.time,
                maxlevel = tool.maxlevel,
              }
            end
          end
        end

        if #best_candidates == 0 and hand_can then
          local result = {
            name = "hand",
            description = format_tool_summary(minimum_types, S("Hand")),
            type = S("Hand"),
            best_type = S("Hand"),
            minimum_types = clone_list(minimum_types),
          }
          eye_spy.cache.tool_info[cache_key] = result
          return result
        end

        table.sort(best_candidates, function(a, b)
          if a.matches_group ~= b.matches_group then
            return a.matches_group and not b.matches_group
          end

          if a.time ~= b.time then
            return a.time < b.time
          end

          if a.maxlevel ~= b.maxlevel then
            return a.maxlevel < b.maxlevel
          end

          return a.type < b.type
        end)

        local best = best_candidates[1]

        if best then
          local result = {
            name = best.type,
            description = format_tool_summary(minimum_types, best.type),
            type = best.type,
            best_type = best.type,
            minimum_types = clone_list(minimum_types),
          }
          eye_spy.cache.tool_info[cache_key] = result
          return result
        else
          return {
            name = "error",
            description = S("Unknown"),
            type = tooltype,
            best_type = tooltype,
            minimum_types = clone_list(minimum_types),
          }
        end
    elseif cg == "mineclone" then
        if node_groups then
          if node_groups.shearsy_wool then
            append_unique(minimum_types, S("Shears"))
          end

          if node_groups.swordy_cobweb then
            append_unique(minimum_types, S("Sword"))
          end

          if node_groups.dig_immediate == 3 then
            append_unique(minimum_types, S("Hand"))
          end

          local best_type = tooltype

          if best_type == S("None") and hand_can then
            best_type = S("Hand")
          end

          if best_type ~= S("None") then
            local result = {
              name = best_type,
              description = format_tool_summary(minimum_types, best_type),
              type = best_type,
              best_type = best_type,
              minimum_types = clone_list(minimum_types),
            }
            eye_spy.cache.tool_info[cache_key] = result
            return result
          end

          local result = {
            name = "unbreakable",
            description = S("Unbreakable"),
            type = S("None"),
            best_type = S("None"),
            minimum_types = clone_list(minimum_types),
          }
          eye_spy.cache.tool_info[cache_key] = result
          return result
        end
    end
end

function eye_spy.get_modname_from_obj(obj_name)
    return obj_name:match("([^:]+)")
end

function eye_spy.round1(x)
  if x then
    return math.floor(x * 10 + 0.5) / 10
  else
    return nil
  end
end

-- Entfernt alles ab dem ersten ^ (Texture-Modifiers)
function strip_texture_modifiers(tex)
    if type(tex) ~= "string" then
        return tex
    end

    local pos = tex:find("%^")
    if not pos then
        return tex
    end

    return tex:sub(1, pos - 1)
end
