local S = eye_spy.S

eye_spy.render = {}
local RENDER_LAYOUT_REV = "advanced-layout-controls-v2-padding-fix"
local ICON_TEXTURE_SIZE = 32
local ICON_DISPLAY_SIZE = 56
local ENTITY_ICON_TEXTURE = "heart.png"
local BASE_LAYOUT_LINE_ROWS = 6
local MAX_LAYOUT_SCALE_FACTOR = 3

local FIXED_LINE_ROW_BY_ID = {
  dig_time = 1,
  light = 2,
  soil = 3,
  growth = 4,
  spawn = 5,
  liquid = 6,
}

local FIXED_LINE_ORDER = {
  "dig_time",
  "light",
  "soil",
  "growth",
  "spawn",
  "liquid",
}

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

local layouts = {
  ["Top-Middle"] = {pos = {x = 0.5, y = 0}, x_mode = "center", y_mode = "top"},
  ["Top-Right"] = {pos = {x = 1, y = 0}, x_mode = "right", y_mode = "top"},
  ["Top-Left"] = {pos = {x = 0, y = 0}, x_mode = "left", y_mode = "top"},
  ["Middle-Right"] = {pos = {x = 1, y = 0.5}, x_mode = "right", y_mode = "middle"},
  ["Middle-Left"] = {pos = {x = 0, y = 0.5}, x_mode = "left", y_mode = "middle"},
  ["Bottom-Right"] = {pos = {x = 1, y = 1}, x_mode = "right", y_mode = "bottom"},
  ["Bottom-Left"] = {pos = {x = 0, y = 1}, x_mode = "left", y_mode = "bottom"},
}

local function clamp(v, minv, maxv)
  return math.max(minv, math.min(maxv, v))
end

local function build_layout_lines(view_model)
  local fixed_slots = {}
  local append_lines = {}

  for _, line in ipairs(view_model.lines or {}) do
    local fixed_row = line.id and FIXED_LINE_ROW_BY_ID[line.id] or nil

    if fixed_row and fixed_slots[line.id] == nil then
      fixed_slots[line.id] = line
    else
      append_lines[#append_lines + 1] = line
    end
  end

  local ordered_lines = {}

  for _, line_id in ipairs(FIXED_LINE_ORDER) do
    if fixed_slots[line_id] then
      ordered_lines[#ordered_lines + 1] = fixed_slots[line_id]
    end
  end

  for _, line in ipairs(append_lines) do
    ordered_lines[#ordered_lines + 1] = line
  end

  local total_rows = #ordered_lines
  local required_rows = math.max(1, total_rows)
  local max_rows = BASE_LAYOUT_LINE_ROWS * MAX_LAYOUT_SCALE_FACTOR
  local visible_rows = math.min(required_rows, max_rows)
  local layout_lines = {}

  for row = 1, visible_rows do
    layout_lines[row] = ordered_lines[row]
  end

  local overflow_count = math.max(0, total_rows - visible_rows)

  if overflow_count > 0 and visible_rows > 0 then
    layout_lines[visible_rows] = {
      id = "overflow",
      text = string.format(S("+%d more"), overflow_count),
      color = 0xAAAAAA,
    }
  end

  return layout_lines, visible_rows
end

local function estimate_panel_width(view_model, layout_lines)
  local max_len = math.max(#(view_model.title or ""), #(view_model.subtitle or ""), #(view_model.footer or ""))

  for _, line in ipairs(layout_lines or view_model.lines or {}) do
    if line then
      max_len = math.max(max_len, #(line.text or ""))
    end
  end

  local baseline_width = view_model.icon and 468 or 420
  local baseline_char_budget = view_model.icon and 176 or 208
  local grow_chars = math.max(0, max_len - baseline_char_budget)
  local required_width = baseline_width + math.floor(grow_chars * 6)

  return clamp(required_width, baseline_width, baseline_width * MAX_LAYOUT_SCALE_FACTOR)
end

local function get_bg_luminance(rgb_hex)
  local r = tonumber(rgb_hex:sub(1, 2), 16) or 26
  local g = tonumber(rgb_hex:sub(3, 4), 16) or 26
  local b = tonumber(rgb_hex:sub(5, 6), 16) or 27
  return (r * 0.299) + (g * 0.587) + (b * 0.114)
end

local function get_bg_luminance_rgb(r, g, b)
  return (r * 0.299) + (g * 0.587) + (b * 0.114)
end

local function channel_luminance(channel)
  local c = channel / 255

  if c <= 0.03928 then
    return c / 12.92
  end

  return ((c + 0.055) / 1.055) ^ 2.4
end

local function split_rgb(color)
  local r = math.floor(color / 65536) % 256
  local g = math.floor(color / 256) % 256
  local b = color % 256
  return r, g, b
end

local function join_rgb(r, g, b)
  return (r * 65536) + (g * 256) + b
end

local function color_luminance(color)
  local r, g, b = split_rgb(color)

  return (0.2126 * channel_luminance(r))
    + (0.7152 * channel_luminance(g))
    + (0.0722 * channel_luminance(b))
end

local function contrast_ratio(color_a, color_b)
  local a_l = color_luminance(color_a)
  local b_l = color_luminance(color_b)

  return (math.max(a_l, b_l) + 0.05) / (math.min(a_l, b_l) + 0.05)
end

local function scale_color(color, factor)
  local r, g, b = split_rgb(color)
  r = clamp(math.floor(r * factor), 0, 255)
  g = clamp(math.floor(g * factor), 0, 255)
  b = clamp(math.floor(b * factor), 0, 255)
  return join_rgb(r, g, b)
end

local function blend_color(color_a, color_b, t)
  local factor = clamp(t or 0.5, 0, 1)
  local ar, ag, ab = split_rgb(color_a)
  local br, bg, bb = split_rgb(color_b)

  return join_rgb(
    math.floor((ar * (1 - factor)) + (br * factor) + 0.5),
    math.floor((ag * (1 - factor)) + (bg * factor) + 0.5),
    math.floor((ab * (1 - factor)) + (bb * factor) + 0.5)
  )
end

local function rgb_to_hsl(r, g, b)
  local rn, gn, bn = r / 255, g / 255, b / 255
  local max_c = math.max(rn, gn, bn)
  local min_c = math.min(rn, gn, bn)
  local l = (max_c + min_c) * 0.5

  if max_c == min_c then
    return 0, 0, l
  end

  local d = max_c - min_c
  local s = l > 0.5 and d / (2 - max_c - min_c) or d / (max_c + min_c)
  local h

  if max_c == rn then
    h = ((gn - bn) / d) % 6
  elseif max_c == gn then
    h = (bn - rn) / d + 2
  else
    h = (rn - gn) / d + 4
  end

  return (h / 6) * 360, s, l
end

local function hsl_to_rgb(h, s, l)
  if s <= 0 then
    local v = clamp(math.floor(l * 255 + 0.5), 0, 255)
    return v, v, v
  end

  local function hue2rgb(p, q, t)
    t = t % 1.0
    if t < 1 / 6 then return p + (q - p) * 6 * t end
    if t < 1 / 2 then return q end
    if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
    return p
  end

  h = h / 360
  local q = l < 0.5 and l * (1 + s) or l + s - l * s
  local p = 2 * l - q

  return clamp(math.floor(hue2rgb(p, q, h + 1 / 3) * 255 + 0.5), 0, 255),
         clamp(math.floor(hue2rgb(p, q, h) * 255 + 0.5), 0, 255),
         clamp(math.floor(hue2rgb(p, q, h - 1 / 3) * 255 + 0.5), 0, 255)
end

local function best_monochrome_for_bg(bg_color)
  local dark = 0x111111
  local light = 0xF0F0F0

  if contrast_ratio(light, bg_color) >= contrast_ratio(dark, bg_color) then
    return light
  end

  return dark
end

local function spectrum_color(val)
  local v = clamp(val or 255, 0, 255)
  local r, g, b = 0, 0, 0

  if v < 85 then
    r = 255 - (v * 3)
    g = v * 3
  elseif v < 170 then
    local x = v - 85
    g = 255 - (x * 3)
    b = x * 3
  elseif v < 230 then
    local x = v - 170
    r = x * 4
    b = 255 - (x * 4)
  else
    local x = math.floor((v - 230) * (255 / 25))
    r, g, b = x, x, x
  end

  return join_rgb(r, g, b)
end

local function adapt_for_bg(color, bg_hex)
  if get_bg_luminance(bg_hex) <= 140 then
    return color
  end

  local luminance = (function(c)
    local r, g, b = split_rgb(c)
    return (r * 0.299) + (g * 0.587) + (b * 0.114)
  end)(color)

  if luminance > 130 then
    return scale_color(color, 0.45)
  end

  return color
end

local function ensure_contrast(color, bg_hex, fallback, min_ratio)
  local bg_r = tonumber(bg_hex:sub(1, 2), 16) or 26
  local bg_g = tonumber(bg_hex:sub(3, 4), 16) or 26
  local bg_b = tonumber(bg_hex:sub(5, 6), 16) or 27
  local bg_color = join_rgb(bg_r, bg_g, bg_b)
  local bg_l = color_luminance(bg_color)
  local fg_l = color_luminance(color)
  local ratio = (math.max(bg_l, fg_l) + 0.05) / (math.min(bg_l, fg_l) + 0.05)

  if ratio >= (min_ratio or 4.5) then
    return color
  end

  return fallback
end

local function nudge_contrast(color, bg_color, min_ratio)
  local candidate = color
  local bg_l = color_luminance(bg_color)

  for _ = 1, 5 do
    if contrast_ratio(candidate, bg_color) >= (min_ratio or 4.5) then
      return candidate
    end

    local fg_l = color_luminance(candidate)

    if fg_l < bg_l then
      candidate = blend_color(candidate, 0x000000, 0.32)
    else
      candidate = blend_color(candidate, 0xFFFFFF, 0.32)
    end
  end

  return candidate
end

local function tune_contrast_preserve_hue(color, bg_color, min_ratio)
  local required_ratio = min_ratio or 4.0
  local candidate = color

  if contrast_ratio(candidate, bg_color) >= required_ratio then
    return candidate
  end

  for step = 1, 8 do
    if contrast_ratio(candidate, bg_color) >= required_ratio then
      return candidate
    end

    local t = clamp(step * 0.09, 0, 0.70)
    local lighter = blend_color(color, 0xFFFFFF, t)
    local darker = blend_color(color, 0x000000, t)

    if contrast_ratio(lighter, bg_color) >= contrast_ratio(darker, bg_color) then
      candidate = lighter
    else
      candidate = darker
    end
  end

  return candidate
end

local function has_line(view_model, line_id)
  for _, line in ipairs(view_model.lines) do
    if line.id == line_id then
      return true
    end
  end

  return false
end

-- Hue offsets (degrees) spread each line type around the complementary of
-- the background hue so they remain visually distinct from each other.
local LINE_HUE_OFFSETS = {
  growth   =   0,
  soil     =  30,
  dig_time =  55,
  light    =  85,
  spawn    =  45,
  liquid   = -50,
}

local auto_line_color_cache = {}
local auto_line_color_cache_size = 0

local function get_auto_line_accent(line_id, bg_hex, bg_color, base_text, bg_luminance)
  local cache_key = table.concat({line_id or "", bg_hex or "", tostring(base_text)}, "|")
  local cached = auto_line_color_cache[cache_key]

  if cached then
    return cached
  end

  local bg_r, bg_g, bg_b = split_rgb(bg_color)
  local bg_h, bg_s, bg_l = rgb_to_hsl(bg_r, bg_g, bg_b)

  -- Dynamic accent: complementary hue + per-line offset, lightness inversely
  -- proportional to bg lightness so it remains readable on any saturated bg
  -- without collapsing to a monochrome colour.
  local hue_off = LINE_HUE_OFFSETS[line_id] or 0
  local accent_h = (bg_h + 180 + hue_off) % 360
  local accent_s = clamp(0.60 + bg_s * 0.22, 0.48, 0.88)
  local accent_l = clamp(0.76 - bg_l * 0.52, 0.20, 0.82)
  local dr, dg, db = hsl_to_rgb(accent_h, accent_s, accent_l)
  local dynamic = join_rgb(dr, dg, db)

  -- Fixed palettes are still used as the starting point for near-neutral
  -- backgrounds where the bg hue is undefined or very weak.
  local dark_palette = {
    growth = 0x8BE68B, soil = 0xE7C882,
    dig_time = 0xAFC9FF, light = 0xF2DE98, spawn = 0xF2B189, liquid = 0x8CC8FF,
  }
  local light_palette = {
    growth = 0x2D7A35, soil = 0x7E5A1F,
    dig_time = 0x345C98, light = 0x725C1D, spawn = 0x94442A, liquid = 0x1E5E9A,
  }
  local lightness_t = clamp((bg_l - 0.35) / 0.30, 0, 1)
  local dark_fixed = dark_palette[line_id] or dynamic
  local light_fixed = light_palette[line_id] or dynamic
  local fixed = blend_color(dark_fixed, light_fixed, lightness_t)

  -- Blend: saturated bg → trust dynamic complementary; neutral bg → fixed.
  local accent = blend_color(fixed, dynamic, clamp(bg_s * 1.3, 0, 1))

  -- Contrast pass without hard-snapping to monochrome fallback.
  local result = tune_contrast_preserve_hue(accent, bg_color, 3.6)
  result = nudge_contrast(result, bg_color, 3.8)

  if auto_line_color_cache_size > 512 then
    auto_line_color_cache = {}
    auto_line_color_cache_size = 0
  end

  if not auto_line_color_cache[cache_key] then
    auto_line_color_cache_size = auto_line_color_cache_size + 1
  end

  auto_line_color_cache[cache_key] = result

  return result
end

local function adjust_semantic_line_color(color, bg_color)
  return nudge_contrast(
    tune_contrast_preserve_hue(color, bg_color, 3.6),
    bg_color,
    3.8
  )
end

local function get_line_color_for_manual(meta, line_id)
  if line_id == "growth" then
    return spectrum_color(meta:get_int("es_growth_color_val"))
  end

  if line_id == "soil" then
    return spectrum_color(meta:get_int("es_soil_color_val"))
  end

  return spectrum_color(meta:get_int("es_line_color_val"))
end

local function position_text(pos)
  if not pos then
    return "0, 0, 0"
  end

  return string.format("%s, %s, %s", eye_spy.round1(pos.x) or 0, eye_spy.round1(pos.y) or 0, eye_spy.round1(pos.z) or 0)
end

local function texture_text(target)
  if target and target.kind == "entity" then
    return ENTITY_ICON_TEXTURE .. "^[resize:" .. ICON_TEXTURE_SIZE .. "x" .. ICON_TEXTURE_SIZE
  end

  local texture = target.texture or "eye_spy_default_texture.png"

  if type(texture) == "table" then
    texture = texture.name or texture.image or "eye_spy_default_texture.png"
  end

  if type(texture) ~= "string" or texture == "" then
    texture = "eye_spy_default_texture.png"
  end

  texture = strip_texture_modifiers(texture)

  if target.color then
    texture = texture .. "^[multiply:" .. target.color
  elseif target.palette then
    texture = texture .. "^[multiply:#7CBD6B"
  end

  -- Normalize every icon texture to the same source size so HUD icon size is consistent.
  texture = texture .. "^[resize:" .. ICON_TEXTURE_SIZE .. "x" .. ICON_TEXTURE_SIZE

  return texture
end

-- Per-player layout signature cache. The 35-key meta scan only needs to run
-- when the player is actively changing layout values in the editor. All other
-- updates (normal play, colour slider dragging) reuse the cached string.
local player_layout_sigs = {}

local function get_player_draft(player)
  if not player or not eye_spy.ui_drafts then
    return nil
  end

  return eye_spy.ui_drafts[player:get_player_name()]
end

local function get_effective_string(meta, draft, key)
  if draft and draft[key] ~= nil then
    return tostring(draft[key])
  end

  return meta:get_string(key)
end

local function get_effective_int(meta, draft, key)
  if draft and draft[key] ~= nil then
    return tonumber(draft[key]) or 0
  end

  return meta:get_int(key)
end

function eye_spy.render.invalidate_layout_sig(player_name)
  player_layout_sigs[player_name] = nil
end

local function get_layout_signature(meta, player_name)
  if player_name and player_layout_sigs[player_name] then
    return player_layout_sigs[player_name]
  end

  local parts = {}

  for _, key in ipairs(layout_key_names) do
    parts[#parts + 1] = tostring(meta:get_int(key))
  end

  local sig = table.concat(parts, ",")

  if player_name then
    player_layout_sigs[player_name] = sig
  end

  return sig
end

local function get_layout_signature_from_values(meta, draft)
  local parts = {}

  for _, key in ipairs(layout_key_names) do
    parts[#parts + 1] = tostring(get_effective_int(meta, draft, key))
  end

  return table.concat(parts, ",")
end

local function build_key(view_model)
  local parts = {
    RENDER_LAYOUT_REV,
    view_model.layout_signature or "",
    view_model.layout,
    view_model.bg_color,
    view_model.title,
    tostring(view_model.title_color),
    view_model.subtitle,
    tostring(view_model.subtitle_color),
    view_model.footer,
    tostring(view_model.footer_color),
    view_model.icon and view_model.icon.text or "",
    tostring(#view_model.lines),
  }

  for _, line in ipairs(view_model.lines) do
    parts[#parts + 1] = line.id
    parts[#parts + 1] = line.text
    parts[#parts + 1] = tostring(line.color)
  end

  return table.concat(parts, "|")
end

local function hud_elem_type(def)
  return def and (def.type or def.hud_elem_type)
end

local function ensure_hud_slot(player, hud_ids, key, expected_type, spec)
  local id = hud_ids[key]

  if id then
    local def = player:hud_get(id)

    if not def or hud_elem_type(def) ~= expected_type then
      if def then
        player:hud_remove(id)
      end

      hud_ids[key] = nil
      id = nil
    end
  end

  if not id then
    hud_ids[key] = player:hud_add(spec)
  end
end

function eye_spy.render.ensure_hud(state, player)
  local hud_ids = state.hud_ids

  ensure_hud_slot(player, hud_ids, "bg", "image", {
    type = "image",
    position = {x = 0.5, y = 0},
    offset = {x = 0, y = 0},
    text = "",
    scale = {x = 400, y = 82},
    alignment = {x = 0, y = 0},
    z_index = 1,
  })

  ensure_hud_slot(player, hud_ids, "icon", "image", {
    type = "image",
    position = {x = 0.5, y = 0},
    offset = {x = 0, y = 0},
    text = "",
    scale = {x = 3.5, y = 3.5},
    alignment = {x = 0, y = 0},
    z_index = 2,
  })

  ensure_hud_slot(player, hud_ids, "title", "text", {
    type = "text",
    position = {x = 0.5, y = 0},
    offset = {x = 0, y = 0},
    text = "",
    number = 0xFFFFFF,
    alignment = {x = 1, y = 0},
    z_index = 2,
  })

  ensure_hud_slot(player, hud_ids, "subtitle", "text", {
    type = "text",
    position = {x = 0.5, y = 0},
    offset = {x = 0, y = 0},
    text = "",
    number = 0xEBB344,
    alignment = {x = 1, y = 0},
    z_index = 2,
  })

  ensure_hud_slot(player, hud_ids, "footer", "text", {
    type = "text",
    position = {x = 0.5, y = 0},
    offset = {x = 0, y = 0},
    text = "",
    number = 0x4343F0,
    alignment = {x = 1, y = 0},
    z_index = 2,
  })
end

function eye_spy.render.ensure_line_slots(state, player, count)
  local line_ids = state.hud_ids.lines

  for i = 1, #line_ids do
    local id = line_ids[i]
    local def = id and player:hud_get(id)

    if not def or hud_elem_type(def) ~= "text" then
      if def then
        player:hud_remove(id)
      end

      line_ids[i] = player:hud_add({
        type = "text",
        position = {x = 0.5, y = 0},
        offset = {x = 0, y = 0},
        text = "",
        number = 0xFFFFFF,
        alignment = {x = 1, y = 0},
        z_index = 2,
      })
    end
  end

  while #line_ids < count do
    line_ids[#line_ids + 1] = player:hud_add({
      type = "text",
      position = {x = 0.5, y = 0},
      offset = {x = 0, y = 0},
      text = "",
      number = 0xFFFFFF,
      alignment = {x = 1, y = 0},
      z_index = 2,
    })
  end
end

function eye_spy.render.hide(state, player)
  if not state or not state.hud_ids.bg then
    return
  end

  local hud_ids = state.hud_ids

  player:hud_change(hud_ids.bg, "text", "")
  player:hud_change(hud_ids.icon, "text", "")
  player:hud_change(hud_ids.title, "text", "")
  player:hud_change(hud_ids.subtitle, "text", "")
  player:hud_change(hud_ids.footer, "text", "")

  for _, line_id in ipairs(hud_ids.lines) do
    player:hud_change(line_id, "text", "")
  end
end

function eye_spy.render.remove(player)
  local state = eye_spy.get_player_state(player)
  local hud_ids = state.hud_ids

  for key, value in pairs(hud_ids) do
    if key == "lines" then
      for _, line_id in ipairs(value) do
        if line_id and player:hud_get(line_id) then
          player:hud_remove(line_id)
        end
      end
    elseif value and player:hud_get(value) then
      player:hud_remove(value)
    end
  end

  state.hud_ids = {lines = {}}
  state.last_render_key = nil
  state.last_geom_key = nil
end

function eye_spy.render.build_view_model(player, target, opts)
  local perf_on = eye_spy.perf and eye_spy.perf.enabled and minetest.get_us_time
  local phase_start_us = perf_on and minetest.get_us_time() or 0
  local meta = player:get_meta()
  local options = opts or {}
  local draft = get_player_draft(player)
  local effective_meta = meta

  if draft then
    effective_meta = {
      get_string = function(_, key)
        return get_effective_string(meta, draft, key)
      end,
      get_int = function(_, key)
        return get_effective_int(meta, draft, key)
      end,
    }
  end

  local r, g, b = eye_spy.get_rgb(get_effective_string(meta, draft, "es_hud_color"))
  local bg_luminance = get_bg_luminance_rgb(r or 26, g or 26, b or 27)
  local bg_color_int = join_rgb(r or 26, g or 26, b or 27)
  local auto_text = get_effective_string(meta, draft, "es_auto_text_color") ~= "false"
  local footer_mode = get_effective_string(meta, draft, "es_footer_mode")
  local show_coords = get_effective_string(meta, draft, "es_show_coords") ~= "false"
  local footer_text
  local coords_text = show_coords and ("  " .. position_text(target.pos)) or ""

  if footer_mode == "advanced" then
    local full_name = target.full_name or target.name or S("Unknown")
    footer_text = "[" .. full_name .. "] [" .. (target.modname or S("Unknown")) .. "]" .. coords_text
  else
    footer_text = "[" .. (target.modname or S("Unknown")) .. "]" .. coords_text
  end

  local view_model = {
    visible = target.kind ~= "air",
    layout = get_effective_string(meta, draft, "es_hud_alignment") ~= "" and get_effective_string(meta, draft, "es_hud_alignment") or "Top-Middle",
    bg_color = eye_spy.rgb_to_hex(r or 26, g or 26, b or 27),
    title = target.description or target.name or S("Unknown"),
    title_color = 0xFFFFFF,
    subtitle = target.kind == "entity" and S("Entity") or (target.kind == "item" and S("Item") or S("Node")),
    subtitle_color = 0xF2D23C,
    footer = footer_text,
    footer_color = 0x4C7EE8,
    lines = {},
    layout_signature = draft and get_layout_signature_from_values(meta, draft) or get_layout_signature(meta, player:get_player_name()),
  }

  if get_effective_string(meta, draft, "es_show_icons") ~= "false" then
    local icon_size = clamp(get_effective_int(meta, draft, "es_icon_size"), 16, 128)

    if icon_size <= 0 then
      icon_size = ICON_DISPLAY_SIZE
    end

    view_model.icon = {
      text = texture_text(target),
      scale = {x = icon_size / ICON_TEXTURE_SIZE, y = icon_size / ICON_TEXTURE_SIZE},
    }
  end

  eye_spy.enrichers.run({player = player, meta = effective_meta, preview = options.preview}, target, view_model)
  if perf_on then
    eye_spy.perf_record("build_vm_enrichers", minetest.get_us_time() - phase_start_us)
    phase_start_us = minetest.get_us_time()
  end

  local show_growth = get_effective_string(meta, draft, "es_show_growth") ~= "false"

  if options.preview and target.kind == "node" then
    if show_growth and not has_line(view_model, "growth") then
      view_model.lines[#view_model.lines + 1] = {
        id = "growth",
        text = S("Growth") .. ": " .. S("Preview Sample"),
        color = 0x55FF55,
      }
    end

    if show_growth and not has_line(view_model, "soil") then
      view_model.lines[#view_model.lines + 1] = {
        id = "soil",
        text = S("Soil") .. ": " .. S("Preview Sample"),
        color = 0xD8B46A,
      }
    end
  end

  if auto_text then
    local base_text = best_monochrome_for_bg(bg_color_int)

    view_model.title_color = base_text
    -- Subtitle/footer: preserve semantic colour (amber for tool, red for health,
    -- blue for footer) and nudge toward readable contrast without snapping to
    -- a monochrome fallback.
    view_model.subtitle_color = nudge_contrast(
      tune_contrast_preserve_hue(view_model.subtitle_color, bg_color_int, 3.6),
      bg_color_int, 3.8
    )
    view_model.footer_color = nudge_contrast(
      tune_contrast_preserve_hue(view_model.footer_color, bg_color_int, 3.6),
      bg_color_int, 3.8
    )

    for _, line in ipairs(view_model.lines) do
      if line.id == "growth" or line.id == "soil" then
        line.color = adjust_semantic_line_color(line.color, bg_color_int)
      else
        line.color = get_auto_line_accent(line.id, view_model.bg_color, bg_color_int, base_text, bg_luminance)
      end
    end
  else
    view_model.title_color = spectrum_color(get_effective_int(meta, draft, "es_title_color_val"))
    view_model.subtitle_color = spectrum_color(get_effective_int(meta, draft, "es_subtitle_color_val"))
    view_model.footer_color = spectrum_color(get_effective_int(meta, draft, "es_footer_color_val"))

    for _, line in ipairs(view_model.lines) do
      if line.id == "growth" or line.id == "soil" then
        line.color = adjust_semantic_line_color(line.color, bg_color_int)
      else
        line.color = spectrum_color(get_effective_int(meta, draft, "es_line_color_val"))
      end
    end
  end

  if perf_on then
    eye_spy.perf_record("build_vm_color_pass", minetest.get_us_time() - phase_start_us)
    phase_start_us = minetest.get_us_time()
  end

  view_model.key = build_key(view_model)

  if perf_on then
    eye_spy.perf_record("build_vm_key_build", minetest.get_us_time() - phase_start_us)
  end

  return view_model
end

function eye_spy.render.apply(state, player, view_model)
  local layout_lines, visible_line_rows = build_layout_lines(view_model)

  eye_spy.render.ensure_hud(state, player)
  eye_spy.render.ensure_line_slots(state, player, visible_line_rows)

  local meta = player:get_meta()
  local draft = get_player_draft(player)
  local layout = layouts[view_model.layout] or layouts["Top-Middle"]
  local line_count = visible_line_rows
  local global_offset_x = get_effective_int(meta, draft, "es_global_offset_x")
  local global_offset_y = get_effective_int(meta, draft, "es_global_offset_y")
  local bg_offset_x = get_effective_int(meta, draft, "es_bg_offset_x")
  local bg_offset_y = get_effective_int(meta, draft, "es_bg_offset_y")
  local icon_offset_x = get_effective_int(meta, draft, "es_icon_offset_x")
  local icon_offset_y = get_effective_int(meta, draft, "es_icon_offset_y")
  local title_offset_x = get_effective_int(meta, draft, "es_title_offset_x")
  local title_offset_y = get_effective_int(meta, draft, "es_title_offset_y")
  local subtitle_offset_x = get_effective_int(meta, draft, "es_subtitle_offset_x")
  local subtitle_offset_y = get_effective_int(meta, draft, "es_subtitle_offset_y")
  local lines_offset_x = get_effective_int(meta, draft, "es_lines_offset_x")
  local lines_offset_y = get_effective_int(meta, draft, "es_lines_offset_y")
  local footer_offset_x = get_effective_int(meta, draft, "es_footer_offset_x")
  local footer_offset_y = get_effective_int(meta, draft, "es_footer_offset_y")
  local bg_pad_left = get_effective_int(meta, draft, "es_bg_pad_left")
  local bg_pad_right = get_effective_int(meta, draft, "es_bg_pad_right")
  local bg_pad_top = get_effective_int(meta, draft, "es_bg_pad_top")
  local bg_pad_bottom = get_effective_int(meta, draft, "es_bg_pad_bottom")
  local bg_extra_w = get_effective_int(meta, draft, "es_bg_extra_w")
  local bg_extra_h = get_effective_int(meta, draft, "es_bg_extra_h")
  local text_base_x_adj = get_effective_int(meta, draft, "es_text_base_x_adj")
  local icon_base_x_adj = get_effective_int(meta, draft, "es_icon_base_x_adj")
  local top_margin_adj = get_effective_int(meta, draft, "es_top_margin_adj")
  local bottom_margin_adj = get_effective_int(meta, draft, "es_bottom_margin_adj")

  local title_y = 16 + title_offset_y
  local subtitle_y = 34 + subtitle_offset_y
  local first_line_y = 52 + get_effective_int(meta, draft, "es_first_line_y_adj")
  local line_step = math.max(10, 18 + get_effective_int(meta, draft, "es_line_step_adj"))
  local footer_nudge = -10 + get_effective_int(meta, draft, "es_footer_nudge_adj")
  local footer_y = first_line_y + (line_count * line_step) + 10 + footer_nudge

  local content_top = title_y - 8 - bg_pad_top
  local content_bottom = footer_y + 22 + bg_pad_bottom
  local base_footer_y = first_line_y + (BASE_LAYOUT_LINE_ROWS * line_step) + 10 + footer_nudge
  local base_content_bottom = base_footer_y + 22 + bg_pad_bottom
  local base_bg_height = (base_content_bottom - content_top) + bg_extra_h
  local max_bg_height = math.max(52, math.floor(base_bg_height * MAX_LAYOUT_SCALE_FACTOR))
  local bg_height = (content_bottom - content_top) + bg_extra_h
  local bg_width = estimate_panel_width(view_model, layout_lines) + bg_pad_left + bg_pad_right + bg_extra_w
  local top_screen_margin = 10 + top_margin_adj
  local bottom_screen_margin = 22 + bottom_margin_adj

  bg_height = clamp(bg_height, 52, max_bg_height)
  bg_width = clamp(bg_width, 240, 1260)

  local origin_y

  if layout.y_mode == "top" then
    origin_y = top_screen_margin
  elseif layout.y_mode == "middle" then
    origin_y = -math.floor(bg_height / 2)
  else
    origin_y = -(bg_height + bottom_screen_margin)
  end

  origin_y = origin_y + global_offset_y

  local center_x = global_offset_x

  if layout.x_mode == "right" then
    center_x = -math.floor(bg_width / 2) - 16
  elseif layout.x_mode == "left" then
    center_x = math.floor(bg_width / 2) + 16
  end

  local left_edge_x = center_x - math.floor(bg_width / 2)
  local icon_x = left_edge_x + 28 + bg_pad_left + icon_base_x_adj + icon_offset_x
  local text_x = left_edge_x + (view_model.icon and 68 or 16) + bg_pad_left + text_base_x_adj

  local bg_scale_x_pct = clamp(get_effective_int(meta, draft, "es_bg_scale_x_pct"), 25, 300)
  local bg_scale_y_pct = clamp(get_effective_int(meta, draft, "es_bg_scale_y_pct"), 25, 300)
  local bg_draw_w = math.max(1, math.floor(bg_width * (bg_scale_x_pct / 100)))
  local bg_draw_h = math.max(1, math.floor(bg_height * (bg_scale_y_pct / 100)))

  local title_scale = clamp(get_effective_int(meta, draft, "es_title_scale_pct"), 40, 300) / 100
  local subtitle_scale = clamp(get_effective_int(meta, draft, "es_subtitle_scale_pct"), 40, 300) / 100
  local line_scale = clamp(get_effective_int(meta, draft, "es_line_scale_pct"), 40, 300) / 100
  local footer_scale = clamp(get_effective_int(meta, draft, "es_footer_scale_pct"), 40, 300) / 100

  local bg_center_y = origin_y + math.floor(bg_height / 2) - bg_pad_top

  -- Geometry channel: position, offset, scale.
  -- Only sent when layout or panel dimensions actually changed.
  local geom_key = (view_model.layout or "")
    .. "|" .. (view_model.layout_signature or "")
    .. "|" .. tostring(bg_draw_w)
    .. "|" .. tostring(bg_draw_h)
    .. "|" .. tostring(line_count)
    .. "|" .. (view_model.icon and tostring(math.floor(view_model.icon.scale.x * 64)) or "0")

  if geom_key ~= (state.last_geom_key or "") then
    state.last_geom_key = geom_key

    player:hud_change(state.hud_ids.bg, "position", layout.pos)
    player:hud_change(state.hud_ids.bg, "offset", {x = center_x + bg_offset_x, y = bg_center_y + bg_offset_y})
    player:hud_change(state.hud_ids.bg, "scale", {x = bg_draw_w, y = bg_draw_h})

    player:hud_change(state.hud_ids.title, "position", layout.pos)
    player:hud_change(state.hud_ids.title, "offset", {x = text_x + title_offset_x, y = origin_y + title_y})
    player:hud_change(state.hud_ids.title, "scale", {x = title_scale, y = title_scale})

    player:hud_change(state.hud_ids.subtitle, "position", layout.pos)
    player:hud_change(state.hud_ids.subtitle, "offset", {x = text_x + subtitle_offset_x, y = origin_y + subtitle_y})
    player:hud_change(state.hud_ids.subtitle, "scale", {x = subtitle_scale, y = subtitle_scale})

    player:hud_change(state.hud_ids.footer, "position", layout.pos)
    player:hud_change(state.hud_ids.footer, "offset", {x = text_x + footer_offset_x, y = origin_y + footer_y + footer_offset_y})
    player:hud_change(state.hud_ids.footer, "scale", {x = footer_scale, y = footer_scale})

    if view_model.icon then
      player:hud_change(state.hud_ids.icon, "position", layout.pos)
      player:hud_change(state.hud_ids.icon, "offset", {x = icon_x, y = origin_y + 34 + icon_offset_y})
      player:hud_change(state.hud_ids.icon, "scale", view_model.icon.scale)
    end

    -- Geometry for all allocated line slots so new/reactivated slots are positioned correctly.
    for index, line_id in ipairs(state.hud_ids.lines) do
      player:hud_change(line_id, "position", layout.pos)
      player:hud_change(line_id, "offset", {
        x = text_x + lines_offset_x,
        y = origin_y + first_line_y + ((index - 1) * line_step) + lines_offset_y,
      })
      player:hud_change(line_id, "scale", {x = line_scale, y = line_scale})
    end
  end

  -- Content channel: text, number/color -- always applied when view_model changed.
  player:hud_change(state.hud_ids.bg, "text", "eye_spy_hud_bg.png^[multiply:#" .. view_model.bg_color)

  player:hud_change(state.hud_ids.title, "text", view_model.title)
  player:hud_change(state.hud_ids.title, "number", view_model.title_color)

  player:hud_change(state.hud_ids.subtitle, "text", view_model.subtitle)
  player:hud_change(state.hud_ids.subtitle, "number", view_model.subtitle_color)

  player:hud_change(state.hud_ids.footer, "text", view_model.footer)
  player:hud_change(state.hud_ids.footer, "number", view_model.footer_color)

  if view_model.icon then
    player:hud_change(state.hud_ids.icon, "text", view_model.icon.text)
  else
    player:hud_change(state.hud_ids.icon, "text", "")
  end

  for index, line_id in ipairs(state.hud_ids.lines) do
    local line = layout_lines[index]

    if line then
      player:hud_change(line_id, "text", line.text)
      player:hud_change(line_id, "number", line.color)
    else
      player:hud_change(line_id, "text", "")
    end
  end
end

function eye_spy.remove_huds(player)
  if eye_spy.render then
    eye_spy.render.remove(player)
  end
end

function eye_spy.get_hud(player, data)
  eye_spy.update_player(player, data or {})
end