--- Numerical backend for the PGFPlots `autonode` library.
--
-- Copyright (C) 2026 Christophe Jorssen.
-- Distributed under LPPL 1.3c or later.
--
-- This module is the numerical backend of pgfplots-autonode.  TeX samples the
-- current pgfplots path and sends candidate positions to Lua in physical canvas
-- coordinates, measured in TeX points.  Lua then chooses one candidate per label
-- under hard geometric constraints and emits TeX callbacks for the final nodes.
--
-- The code deliberately avoids TeX-specific logic.  Node contents and TikZ
-- options remain on the TeX side; Lua only sees geometry, priorities, costs and
-- solver configuration.
--
-- @module pgfplots-autonode
-- @author Christophe Jorssen
-- @license LPPL-1.3c-or-later

local M = {
  _NAME = "pgfplots-autonode",
  _VERSION = "1.0.0",
}

local labels = {}
local config = {}

local DEFAULT_CONFIG = {
  debug_level = 0,
  max_iterations = 40,
  algorithm = "repair",
  bbox_mode = "axis-aligned",
  failure_mode = "warn",
  border_margin = 2.0,
  overlap_tolerance = 0.2,
  allow_outside = false,
  show_bounding_boxes = false,
  show_candidates = false,
  show_rejected_candidates = false,
  exact_max_labels = 10,
  exact_max_states = 50000,
}

local axis_rect = nil

local function reset_config()
  config = {}
  for key, value in pairs(DEFAULT_CONFIG) do
    config[key] = value
  end
end

reset_config()

local function log(level, message)
  if (config.debug_level or 0) >= level then
    texio.write_nl("term and log", "pgfplots-autonode: " .. message)
  end
end

local function warn(message)
  texio.write_nl("term and log", "pgfplots-autonode warning: " .. message)
end

local function package_error(message)
  if tex and tex.error then
    texio.write_nl("term and log", "pgfplots-autonode error: " .. message)
    tex.error("pgfplots-autonode error", { message })
  else
    error("pgfplots-autonode error: " .. message, 2)
  end
end

local function clamp(value, lo, hi)
  if value < lo then return lo end
  if value > hi then return hi end
  return value
end

local function validated_number(value, fallback, name, minimum, maximum,
    integer)
  local parsed = tonumber(value)
  local valid = parsed ~= nil and parsed == parsed
    and parsed ~= math.huge and parsed ~= -math.huge
  if valid and minimum ~= nil and parsed < minimum then valid = false end
  if valid and maximum ~= nil and parsed > maximum then valid = false end
  if valid and integer and parsed ~= math.floor(parsed) then valid = false end
  if valid then return parsed end
  warn("invalid " .. tostring(name) .. " `" .. tostring(value)
    .. "'; using " .. tostring(fallback))
  return fallback
end

local function tex_number(value)
  return string.format("%.6f", value or 0)
end

local function fmt(value)
  return string.format("%.4f", value or 0)
end

local function bool(value)
  return value and true or false
end

local function radians_to_degrees(angle)
  return angle * 180.0 / math.pi
end

-- Return an equivalent rotation angle that keeps text upright.  PGF/TikZ's
-- `sloped` nodes avoid upside-down text unless explicitly requested; the
-- autonode backend draws rotated nodes itself, therefore it must reproduce this
-- convention in Lua.
local function upright_angle(angle)
  angle = tonumber(angle) or 0.0
  while angle > 180.0 do angle = angle - 360.0 end
  while angle <= -180.0 do angle = angle + 360.0 end
  if angle > 90.0 then
    angle = angle - 180.0
  elseif angle < -90.0 then
    angle = angle + 180.0
  end
  return angle
end

local function atan2(y, x)
  -- LuaTeX provides the two-argument form math.atan(y, x).  The wrapper makes
  -- the intended operation explicit and keeps potential compatibility changes in
  -- one place.
  return math.atan(y, x)
end

local function ordered_labels()
  local list = {}
  for _, label in pairs(labels) do
    list[#list + 1] = label
  end
  table.sort(list, function(a, b)
    if a.priority ~= b.priority then
      return a.priority > b.priority
    end
    return a.id < b.id
  end)
  return list
end

local function ordered_labels_by_id()
  local list = {}
  for _, label in pairs(labels) do
    list[#list + 1] = label
  end
  table.sort(list, function(a, b) return a.id < b.id end)
  return list
end

local function aabb_overlap_area(a, b)
  local dx = math.min(a.aabb.xmax, b.aabb.xmax) - math.max(a.aabb.xmin, b.aabb.xmin)
  local dy = math.min(a.aabb.ymax, b.aabb.ymax) - math.max(a.aabb.ymin, b.aabb.ymin)
  if dx > 0 and dy > 0 then
    return dx * dy
  end
  return 0.0
end

local function aabb_overlap_dims(a, b)
  local dx = math.min(a.aabb.xmax, b.aabb.xmax) - math.max(a.aabb.xmin, b.aabb.xmin)
  local dy = math.min(a.aabb.ymax, b.aabb.ymax) - math.max(a.aabb.ymin, b.aabb.ymin)
  if dx > 0 and dy > 0 then
    return dx, dy
  end
  return 0.0, 0.0
end

local function dot(ax, ay, bx, by)
  return ax * bx + ay * by
end

local function projected_interval(corners, ax, ay)
  local first = corners[1]
  local min_value = dot(first.x, first.y, ax, ay)
  local max_value = min_value
  for i = 2, #corners do
    local value = dot(corners[i].x, corners[i].y, ax, ay)
    if value < min_value then min_value = value end
    if value > max_value then max_value = value end
  end
  return min_value, max_value
end

local function oriented_overlap(a, b, tolerance)
  -- Separating axis theorem for two oriented rectangles.  The axes to test are
  -- the local edge normals of both rectangles.  This is still O(1) per pair but
  -- has a larger constant than an axis-aligned test.
  local axes = {
    { x = a.ux, y = a.uy },
    { x = a.vx, y = a.vy },
    { x = b.ux, y = b.uy },
    { x = b.vx, y = b.vy },
  }
  for _, axis in ipairs(axes) do
    local amin, amax = projected_interval(a.corners, axis.x, axis.y)
    local bmin, bmax = projected_interval(b.corners, axis.x, axis.y)
    local overlap = math.min(amax, bmax) - math.max(amin, bmin)
    if overlap <= tolerance then
      return false
    end
  end
  return true
end

local function boxes_overlap(a, b)
  local tolerance = config.overlap_tolerance or 0.0
  if config.bbox_mode == "oriented" then
    return oriented_overlap(a, b, tolerance)
  end
  local dx, dy = aabb_overlap_dims(a, b)
  return dx > tolerance and dy > tolerance
end

local function overlap_penalty(a, b)
  if not boxes_overlap(a, b) then
    return 0.0
  end
  local area = aabb_overlap_area(a, b)
  if area <= 0 then
    -- Oriented rectangles may overlap while their AABB intersection is tiny due
    -- to numerical tolerance.  Return a small positive penalty so that the
    -- optimizer still treats the pair as undesirable.
    return 1.0
  end
  return area
end

local function make_box(label, candidate)
  local angle = label.sloped and candidate.angle or 0.0
  local rad = math.rad(angle)
  local ux, uy = math.cos(rad), math.sin(rad)
  local vx, vy = -math.sin(rad), math.cos(rad)

  local cx, cy = candidate.x, candidate.y
  local xmin = label.local_xmin - label.clearance
  local xmax = label.local_xmax + label.clearance
  local ymin = label.local_ymin - label.clearance
  local ymax = label.local_ymax + label.clearance
  local function corner(x, y)
    return {x = cx + x * ux + y * vx, y = cy + x * uy + y * vy}
  end

  local corners = {
    corner(xmin, ymin),
    corner(xmax, ymin),
    corner(xmax, ymax),
    corner(xmin, ymax),
  }

  local xmin, xmax = corners[1].x, corners[1].x
  local ymin, ymax = corners[1].y, corners[1].y
  for i = 2, #corners do
    local c = corners[i]
    if c.x < xmin then xmin = c.x end
    if c.x > xmax then xmax = c.x end
    if c.y < ymin then ymin = c.y end
    if c.y > ymax then ymax = c.y end
  end

  return {
    aabb = { xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax },
    corners = corners,
    ux = ux, uy = uy,
    vx = vx, vy = vy,
  }
end

local function candidate_inside_visible_box(candidate)
  if config.allow_outside then
    return true, nil
  end
  if not axis_rect then
    return false, "visible axis rectangle is unavailable"
  end

  local margin = config.border_margin or 0.0
  local left = axis_rect.left + margin
  local right = axis_rect.right - margin
  local bottom = axis_rect.bottom + margin
  local top = axis_rect.top - margin

  if config.bbox_mode == "oriented" then
    for _, corner in ipairs(candidate.box.corners) do
      if corner.x < left then
        return false, string.format("outside visible box: left violation %.4fpt", left - corner.x)
      end
      if corner.x > right then
        return false, string.format("outside visible box: right violation %.4fpt", corner.x - right)
      end
      if corner.y < bottom then
        return false, string.format("outside visible box: bottom violation %.4fpt", bottom - corner.y)
      end
      if corner.y > top then
        return false, string.format("outside visible box: top violation %.4fpt", corner.y - top)
      end
    end
    return true, nil
  end

  local box = candidate.box.aabb
  if box.xmin < left then
    return false, string.format("outside visible box: left violation %.4fpt", left - box.xmin)
  end
  if box.xmax > right then
    return false, string.format("outside visible box: right violation %.4fpt", box.xmax - right)
  end
  if box.ymin < bottom then
    return false, string.format("outside visible box: bottom violation %.4fpt", bottom - box.ymin)
  end
  if box.ymax > top then
    return false, string.format("outside visible box: top violation %.4fpt", box.ymax - top)
  end
  return true, nil
end

local function local_cost(label, candidate)
  return label.preferred_weight * (candidate.pos - label.preferred) ^ 2
end

local function count_potential_conflicts(label)
  local count = 0
  for _, other in pairs(labels) do
    if other.id ~= label.id then
      for _, c1 in ipairs(label.valid_candidates) do
        for _, c2 in ipairs(other.valid_candidates or {}) do
          if boxes_overlap(c1.box, c2.box) then
            count = count + 1
            break
          end
        end
      end
    end
  end
  return count
end

local function sort_candidates_for_label(label)
  table.sort(label.candidates, function(a, b)
    if a.valid ~= b.valid then
      return a.valid and not b.valid
    end
    if math.abs(a.local_cost - b.local_cost) > 1e-12 then
      return a.local_cost < b.local_cost
    end
    return a.pos < b.pos
  end)

  label.valid_candidates = {}
  label.invalid_candidates = {}
  for _, candidate in ipairs(label.candidates) do
    if candidate.valid then
      label.valid_candidates[#label.valid_candidates + 1] = candidate
    else
      label.invalid_candidates[#label.invalid_candidates + 1] = candidate
    end
  end
end

local function prepare_candidates(label)
  local candidates = label.candidates
  local n = #candidates
  if n == 0 then
    log(1, string.format("label #%d has no candidate", label.id))
    return
  end

  table.sort(candidates, function(a, b) return a.pos < b.pos end)

  for i, candidate in ipairs(candidates) do
    -- Tangents are estimated in physical canvas coordinates.  This is essential
    -- for anisotropic axes: a data slope is not the same thing as a visual slope.
    local previous_candidate = candidates[math.max(1, i - 1)]
    local next_candidate = candidates[math.min(n, i + 1)]
    if previous_candidate.component ~= candidate.component then
      previous_candidate = candidate
    end
    if next_candidate.component ~= candidate.component then
      next_candidate = candidate
    end
    local dx = next_candidate.base_x - previous_candidate.base_x
    local dy = next_candidate.base_y - previous_candidate.base_y
    local angle = 0.0

    if math.abs(dx) + math.abs(dy) > 1e-9 then
      angle = radians_to_degrees(atan2(dy, dx))
    end
    if label.sloped and not label.allow_upside_down then
      angle = upright_angle(angle)
    end

    candidate.angle = angle

    -- Signed normal shift.  Positive values use the left normal of the local
    -- tangent, matching the user-level aliases `above` and `below`.
    local rad = math.rad(angle)
    candidate.x = candidate.base_x - math.sin(rad) * label.normal_shift
    candidate.y = candidate.base_y + math.cos(rad) * label.normal_shift
    candidate.box = make_box(label, candidate)
    candidate.local_cost = local_cost(label, candidate)

    local inside, reason = candidate_inside_visible_box(candidate)
    candidate.valid = inside
    candidate.rejection_reason = reason

    if candidate.valid then
      log(3, string.format(
        "candidate label #%d pos=%s center=(%s,%s) angle=%s valid",
        label.id, fmt(candidate.pos), fmt(candidate.x), fmt(candidate.y), fmt(angle)
      ))
    else
      log(2, string.format(
        "reject label #%d candidate pos=%s: %s",
        label.id, fmt(candidate.pos), reason or "invalid"
      ))
    end
  end

  sort_candidates_for_label(label)
end

local function compute_difficulty(label)
  local area = (label.width + 2 * label.clearance) * (label.height + 2 * label.clearance)
  local candidates = #(label.valid_candidates or {})
  local scarcity = candidates > 0 and 1.0 / candidates or 1e6
  return 1000.0 * label.priority + 0.01 * area + 100.0 * scarcity + count_potential_conflicts(label)
end

local function ordered_by_difficulty(list)
  local sorted = {}
  for i, label in ipairs(list) do sorted[i] = label end
  table.sort(sorted, function(a, b)
    local da, db = compute_difficulty(a), compute_difficulty(b)
    if math.abs(da - db) > 1e-9 then
      return da > db
    end
    return a.id < b.id
  end)
  return sorted
end

local function candidate_conflicts_with_placed(candidate, placed)
  local penalty = 0.0
  local details = {}
  for _, other in ipairs(placed) do
    if other.choice then
      local p = overlap_penalty(candidate.box, other.choice.box)
      if p > 0 then
        penalty = penalty + p
        details[#details + 1] = string.format("overlap with #%d, approximate area=%s", other.id, fmt(p))
      end
    end
  end
  return penalty > 0, penalty, details
end

local function total_overlap_penalty_for_label(label, candidate, list)
  local penalty = 0.0
  local details = {}
  for _, other in ipairs(list) do
    if other.id ~= label.id and other.choice and not other.hidden then
      local area = overlap_penalty(candidate.box, other.choice.box)
      if area > 0 then
        -- A pair belongs to both labels.  Averaging their declared weights
        -- gives one symmetric contribution to the assignment objective and
        -- makes both labels' declared preferences participate.
        local pair_weight = 0.5 * ((label.overlap_weight or 1000.0)
          + (other.overlap_weight or 1000.0))
        local contribution = pair_weight * area
        penalty = penalty + contribution
        details[#details + 1] = string.format(
          "overlap with #%d, approximate area=%s weighted cost=%s",
          other.id, fmt(area), fmt(contribution))
      end
    end
  end
  return penalty, details
end

local function final_status(list)
  local overlaps = 0
  local hidden = 0
  local outside = 0
  for _, label in ipairs(list) do
    if label.hidden then hidden = hidden + 1 end
    if label.choice and not candidate_inside_visible_box(label.choice) then outside = outside + 1 end
  end
  for i = 1, #list do
    local a = list[i]
    if a.choice and not a.hidden then
      for j = i + 1, #list do
        local b = list[j]
        if b.choice and not b.hidden and boxes_overlap(a.choice.box, b.choice.box) then
          overlaps = overlaps + 1
        end
      end
    end
  end
  return { overlaps = overlaps, hidden = hidden, outside = outside }
end

local function greedy_place(list)
  local order = ordered_by_difficulty(list)
  local placed = {}

  for _, label in ipairs(order) do
    label.choice = nil
    label.hidden = false

    for _, candidate in ipairs(label.valid_candidates or {}) do
      local conflicts, penalty, details = candidate_conflicts_with_placed(candidate, placed)
      log(2, string.format(
        "greedy try label #%d pos=%s cost=%s conflicts=%s",
        label.id, fmt(candidate.pos), fmt(candidate.local_cost), tostring(conflicts)
      ))
      if conflicts then
        for _, detail in ipairs(details) do
          log(2, "  reject: " .. detail)
        end
      else
        label.choice = candidate
        placed[#placed + 1] = label
        log(1, string.format("greedy place label #%d at pos=%s", label.id, fmt(candidate.pos)))
        break
      end
    end

    if not label.choice then
      log(1, string.format("greedy could not place label #%d without conflicts", label.id))
      if config.failure_mode == "hide-low-priority" then
        label.hidden = true
        log(1, string.format("hide label #%d according to failure mode", label.id))
      else
        -- Keep a fallback candidate so that `allow-minimal-overlap` and `warn`
        -- can still draw something.  The final failure handler decides whether
        -- this is acceptable.
        label.choice = label.valid_candidates[1]
        placed[#placed + 1] = label
      end
    end
  end
end

local function local_search(list, only_conflicting)
  -- Discrete coordinate descent under hard visibility constraints.  Candidates
  -- that overlap existing choices are allowed during the search only when no
  -- non-overlapping alternative exists; the final status still reports conflicts.
  for iteration = 1, config.max_iterations do
    local changed = false
    log(2, string.format("local-search iteration %d", iteration))

    for _, label in ipairs(list) do
      if not label.hidden and #(label.valid_candidates or {}) > 0 then
        if not only_conflicting or not label.choice then
          -- proceed
        elseif label.choice then
          local current_penalty = total_overlap_penalty_for_label(label, label.choice, list)
          if current_penalty <= 0 then
            goto continue_label
          end
        end

        local current = label.choice
        local current_total = math.huge
        if current then
          local overlap = total_overlap_penalty_for_label(label, current, list)
          current_total = current.local_cost + overlap
        end

        local best_candidate = current
        local best_total = current_total
        local best_overlap = current and total_overlap_penalty_for_label(label, current, list) or math.huge
        local best_details = {}

        for _, candidate in ipairs(label.valid_candidates) do
          local overlap, details = total_overlap_penalty_for_label(label, candidate, list)
          local total = candidate.local_cost + overlap
          log(2, string.format(
            "local-search try label #%d pos=%s total=%s local=%s overlap=%s",
            label.id, fmt(candidate.pos), fmt(total), fmt(candidate.local_cost), fmt(overlap)
          ))
          if #details > 0 then
            for _, detail in ipairs(details) do log(2, "  conflict: " .. detail) end
          end
          if total < best_total - 1e-9 then
            best_candidate = candidate
            best_total = total
            best_overlap = overlap
            best_details = details
          end
        end

        if best_candidate and best_candidate ~= current then
          log(1, string.format(
            "move label #%d: pos %s -> %s, score %s -> %s",
            label.id,
            fmt(current and current.pos or -1),
            fmt(best_candidate.pos),
            fmt(current_total),
            fmt(best_total)
          ))
          if best_overlap > 0 then
            for _, detail in ipairs(best_details) do log(1, "  remaining " .. detail) end
          end
          label.choice = best_candidate
          changed = true
        end
      end
      ::continue_label::
    end

    if not changed then
      log(1, string.format("local-search converged after %d iteration(s)", iteration))
      break
    end
  end
end

local function solve_greedy(list)
  greedy_place(list)
end

local function solve_repair(list)
  greedy_place(list)
  local_search(list, true)
end

local function solve_local_search(list)
  -- Initialise all labels independently at the closest valid candidate to the
  -- preferred position, then run coordinate descent on the global objective.
  for _, label in ipairs(list) do
    label.hidden = false
    label.choice = label.valid_candidates[1]
    if label.choice then
      log(1, string.format("initial label #%d at pos=%s", label.id, fmt(label.choice.pos)))
    end
  end
  local_search(list, false)
end

local function exact_search(list)
  local order = ordered_by_difficulty(list)
  local best_cost = math.huge
  local best_assignment = {}
  local states = 0
  local max_states = config.exact_max_states
  local feasible_found = false

  local function recurse(index, cost)
    if states >= max_states then return end
    states = states + 1
    if cost >= best_cost then return end

    if index > #order then
      feasible_found = true
      best_cost = cost
      best_assignment = {}
      for _, label in ipairs(order) do
        best_assignment[label.id] = label.choice
      end
      log(1, string.format("exact-small new best cost=%s after %d state(s)", fmt(best_cost), states))
      return
    end

    local label = order[index]
    label.hidden = false
    local previous = label.choice

    for _, candidate in ipairs(label.valid_candidates or {}) do
      local conflicts = false
      for j = 1, index - 1 do
        local other = order[j]
        if other.choice and boxes_overlap(candidate.box, other.choice.box) then
          conflicts = true
          log(2, string.format(
            "exact-small reject label #%d pos=%s: overlaps label #%d",
            label.id, fmt(candidate.pos), other.id
          ))
          break
        end
      end
      if not conflicts then
        label.choice = candidate
        recurse(index + 1, cost + candidate.local_cost)
        if states >= max_states then break end
      end
    end

    label.choice = previous
  end

  if #list > config.exact_max_labels then
    log(1, string.format(
      "exact-small skipped: %d labels exceed exact max labels=%d; using repair",
      #list, config.exact_max_labels
    ))
    solve_repair(list)
    return
  end

  recurse(1, 0.0)

  if not feasible_found then
    log(1, string.format(
      "exact-small found no feasible assignment after %d state(s); using repair",
      states
    ))
    solve_repair(list)
    return
  end

  if states >= max_states then
    log(1, string.format("exact-small reached max states=%d", max_states))
  end

  for _, label in ipairs(list) do
    label.choice = best_assignment[label.id]
  end
  log(1, string.format("exact-small finished: states=%d best cost=%s", states, fmt(best_cost)))
end

local function handle_final_failures(list)
  local status = final_status(list)
  if status.overlaps == 0 and status.outside == 0 then
    return status
  end

  local message = string.format(
    "placement has %d overlapping pair(s), %d outside label(s), %d hidden label(s)",
    status.overlaps, status.outside, status.hidden
  )

  if config.failure_mode == "error" then
    package_error(message)
  elseif config.failure_mode == "hide-low-priority" then
    -- Greedy may already have hidden labels.  Hide the lowest-priority label of
    -- each remaining conflicting pair until the status becomes feasible or no
    -- more action is possible.
    local changed = true
    while changed do
      changed = false
      for i = 1, #list do
        local a = list[i]
        if a.choice and not a.hidden then
          for j = i + 1, #list do
            local b = list[j]
            if b.choice and not b.hidden and boxes_overlap(a.choice.box, b.choice.box) then
              local victim = a
              if b.priority < a.priority or (b.priority == a.priority and b.id > a.id) then
                victim = b
              end
              victim.hidden = true
              changed = true
              log(1, string.format("hide label #%d to remove a remaining conflict", victim.id))
              break
            end
          end
        end
        if changed then break end
      end
    end
  elseif config.failure_mode == "allow-minimal-overlap" then
    warn(message .. "; drawing least-cost assignment because failure mode is allow-minimal-overlap")
  else
    warn(message)
  end

  return final_status(list)
end

local function prepare_all_labels(list)
  local active = {}
  for _, label in ipairs(list) do
    prepare_candidates(label)
    log(1, string.format(
      "label #%d: %d candidate(s), %d valid inside visible box",
      label.id, #label.candidates, #(label.valid_candidates or {})
    ))
    if #(label.valid_candidates or {}) == 0 then
      label.choice = nil
      label.hidden = true
      local message = string.format(
        "label #%d has no valid candidate inside the visible box", label.id)
      if config.failure_mode == "error" then
        package_error(message)
      else
        warn(message .. "; hiding label")
      end
    else
      active[#active + 1] = label
    end
  end
  return active
end

local function enforce_visibility(list)
  for _, label in ipairs(list) do
    if label.choice and not label.hidden then
      local inside, reason = candidate_inside_visible_box(label.choice)
      if not inside then
        label.choice = nil
        label.hidden = true
        local message = string.format(
          "label #%d selected an outside candidate (%s)",
          label.id, reason or "unknown reason")
        if config.failure_mode == "error" then
          package_error(message)
        else
          warn(message .. "; hiding label")
        end
      end
    end
  end
end

--- Reset all axis-local labels, candidates, and geometry.
-- Configuration values are preserved for the next axis.
function M.reset()
  labels = {}
  axis_rect = nil
  log(2, "reset axis state")
end

--- Set the diagnostic verbosity.
-- @tparam number level Non-negative debug level.
function M.set_debug(level)
  config.debug_level = validated_number(level, DEFAULT_CONFIG.debug_level,
    "debug level", 0, nil, true)
  if config.debug_level > 0 then
    texio.write_nl("term and log", "pgfplots-autonode: debug level=" .. tostring(config.debug_level))
  end
end

--- Configure the placement solver for the current axis.
-- Unknown table fields are ignored. Invalid enumerated or numerical values
-- produce a warning and fall back to their documented defaults.
-- @tparam[opt={}] table options Solver configuration.
function M.configure(options)
  options = options or {}
  for key, value in pairs(options) do
    if key == "debug_level" then
      config.debug_level = validated_number(value,
        DEFAULT_CONFIG.debug_level, "debug level", 0, nil, true)
    elseif key == "max_iterations" then
      config.max_iterations = validated_number(value,
        DEFAULT_CONFIG.max_iterations, "max iterations", 1, nil, true)
    elseif key == "algorithm" then
      if value == "greedy" or value == "repair" or value == "local-search" or value == "exact-small" then
        config.algorithm = value
      else
        warn("unknown algorithm `" .. tostring(value) .. "'; using repair")
        config.algorithm = "repair"
      end
    elseif key == "bbox_mode" then
      if value == "axis-aligned" or value == "oriented" then
        config.bbox_mode = value
      else
        warn("unknown bbox mode `" .. tostring(value) .. "'; using axis-aligned")
        config.bbox_mode = "axis-aligned"
      end
    elseif key == "failure_mode" then
      if value == "warn" or value == "error" or value == "hide-low-priority" or value == "allow-minimal-overlap" then
        config.failure_mode = value
      else
        warn("unknown failure mode `" .. tostring(value) .. "'; using warn")
        config.failure_mode = "warn"
      end
    elseif key == "border_margin" then
      config.border_margin = validated_number(value,
        DEFAULT_CONFIG.border_margin, "border margin", 0)
    elseif key == "overlap_tolerance" then
      config.overlap_tolerance = validated_number(value,
        DEFAULT_CONFIG.overlap_tolerance, "overlap tolerance", 0)
    elseif key == "allow_outside" then
      config.allow_outside = bool(value)
    elseif key == "show_bounding_boxes" then
      config.show_bounding_boxes = bool(value)
    elseif key == "show_candidates" then
      config.show_candidates = bool(value)
    elseif key == "show_rejected_candidates" then
      config.show_rejected_candidates = bool(value)
    elseif key == "exact_max_labels" then
      config.exact_max_labels = validated_number(value,
        DEFAULT_CONFIG.exact_max_labels, "exact max labels", 1, nil, true)
    elseif key == "exact_max_states" then
      config.exact_max_states = validated_number(value,
        DEFAULT_CONFIG.exact_max_states, "exact max states", 1, nil, true)
    end
  end

  log(1, string.format(
    "configuration: algorithm=%s bbox=%s failure=%s margin=%spt tolerance=%spt",
    config.algorithm, config.bbox_mode, config.failure_mode,
    fmt(config.border_margin), fmt(config.overlap_tolerance)
  ))
end

--- Set the visible axis rectangle in TeX points.
-- @tparam number left Left canvas coordinate.
-- @tparam number right Right canvas coordinate.
-- @tparam number bottom Bottom canvas coordinate.
-- @tparam number top Top canvas coordinate.
function M.set_axis_rect(left, right, bottom, top)
  left = validated_number(left, 0.0, "axis left")
  right = validated_number(right, 0.0, "axis right")
  bottom = validated_number(bottom, 0.0, "axis bottom")
  top = validated_number(top, 0.0, "axis top")
  axis_rect = {
    left = math.min(left, right),
    right = math.max(left, right),
    bottom = math.min(bottom, top),
    top = math.max(bottom, top),
  }
  log(1, string.format(
    "visible rectangle: left=%s right=%s bottom=%s top=%s",
    fmt(axis_rect.left), fmt(axis_rect.right), fmt(axis_rect.bottom), fmt(axis_rect.top)
  ))
end

--- Register one measured label.
-- The positional and size values use TeX points, except `preferred`, which is
-- a normalized path position in the interval [0,1].
-- @tparam number id Positive label identifier.
-- @tparam number width Measured label width.
-- @tparam number height Measured label height.
-- @tparam number preferred Preferred normalized path position.
-- @tparam number normal_shift Shift perpendicular to the curve.
-- @tparam number clearance Extra collision clearance.
-- @tparam boolean sloped Whether to rotate the label with the curve.
-- @tparam boolean allow_upside_down Whether raw path rotation may be inverted.
-- @tparam number preferred_weight Cost of moving from the preferred position.
-- @tparam number overlap_weight Nonnegative overlap-area weight. A pair uses
-- the arithmetic mean of the two labels' weights during local optimization.
-- @tparam number priority Placement priority; higher values are handled first.
function M.add_label(id, width, height, preferred, normal_shift, clearance, sloped, allow_upside_down, preferred_weight, overlap_weight, priority, local_xmin, local_xmax, local_ymin, local_ymax)
  id = validated_number(id, 0, "label id", 1, nil, true)
  if id <= 0 then
    warn("ignored label with invalid id")
    return
  end

  width = validated_number(width, 0.0, "label width", 0)
  height = validated_number(height, 0.0, "label height", 0)
  local_xmin = local_xmin == nil and -width / 2
    or validated_number(local_xmin, -width / 2, "node left offset")
  local_xmax = local_xmax == nil and width / 2
    or validated_number(local_xmax, width / 2, "node right offset")
  local_ymin = local_ymin == nil and -height / 2
    or validated_number(local_ymin, -height / 2, "node bottom offset")
  local_ymax = local_ymax == nil and height / 2
    or validated_number(local_ymax, height / 2, "node top offset")
  if local_xmax < local_xmin or local_ymax < local_ymin then
    package_error("invalid measured node bounds for label #" .. id)
  end

  labels[id] = {
    id = id,
    width = width,
    height = height,
    local_xmin = local_xmin,
    local_xmax = local_xmax,
    local_ymin = local_ymin,
    local_ymax = local_ymax,
    preferred = validated_number(preferred, 0.5,
      "preferred position", 0, 1),
    normal_shift = validated_number(normal_shift, 0.0, "normal shift"),
    clearance = validated_number(clearance, 0.0, "label clearance", 0),
    sloped = bool(sloped),
    allow_upside_down = bool(allow_upside_down),
    preferred_weight = validated_number(preferred_weight, 8.0,
      "preferred weight", 0),
    overlap_weight = validated_number(overlap_weight, 1000.0,
      "overlap weight", 0),
    priority = validated_number(priority, 0.0, "priority"),
    candidates = {},
    path_points = {},
    valid_candidates = {},
    invalid_candidates = {},
    choice = nil,
    hidden = false,
  }

  log(1, string.format(
    "registered label #%d: width=%spt height=%spt preferred=%s normal_shift=%spt sloped=%s allow_upside_down=%s priority=%s",
    id,
    fmt(labels[id].width),
    fmt(labels[id].height),
    fmt(labels[id].preferred),
    fmt(labels[id].normal_shift),
    tostring(labels[id].sloped),
    tostring(labels[id].allow_upside_down),
    fmt(labels[id].priority)
  ))
end

--- Replace provisional dimensions with the actual unrotated TikZ node bounds.
-- Bounds are relative to the final drawing anchor, not to a presumed center.
function M.set_label_geometry(id, xmin, xmax, ymin, ymax)
  local label = labels[validated_number(id, 0, "label id", 1, nil, true)]
  if not label then
    package_error("cannot measure an unknown label #" .. tostring(id))
    return
  end
  xmin = validated_number(xmin, 0, "node left offset")
  xmax = validated_number(xmax, 0, "node right offset")
  ymin = validated_number(ymin, 0, "node bottom offset")
  ymax = validated_number(ymax, 0, "node top offset")
  if xmax <= xmin or ymax <= ymin then
    package_error("invalid measured node bounds for label #" .. id)
    return
  end
  label.local_xmin, label.local_xmax = xmin, xmax
  label.local_ymin, label.local_ymax = ymin, ymax
  label.width, label.height = xmax - xmin, ymax - ymin
  log(2, string.format(
    "measured label #%d: offsets=(%s,%s,%s,%s)pt",
    id, fmt(xmin), fmt(xmax), fmt(ymin), fmt(ymax)))
end

--- Add one sampled position for a registered label.
-- @tparam number id Label identifier.
-- @tparam number pos Normalized position on the PGFPlots path.
-- @tparam number x Canvas x coordinate in TeX points.
-- @tparam number y Canvas y coordinate in TeX points.
function M.add_candidate(id, pos, x, y, component)
  id = validated_number(id, 0, "candidate label id", 1, nil, true)
  local label = labels[id]
  if not label then
    warn(string.format("ignored candidate for unknown label #%s", tostring(id)))
    return
  end
  local candidate_x = validated_number(x, 0.0, "candidate x coordinate")
  local candidate_y = validated_number(y, 0.0, "candidate y coordinate")

  label.candidates[#label.candidates + 1] = {
    pos = validated_number(pos, 0.5, "candidate position", 0, 1),
    base_x = candidate_x,
    base_y = candidate_y,
    x = candidate_x,
    y = candidate_y,
    component = component == nil and 0
      or validated_number(component, 0, "candidate component", 0, nil, true),
    valid = true,
    rejection_reason = nil,
  }
end

local candidate_positions_uniform
local candidate_positions_around_preferred
local candidate_positions_adaptive

--- Begin a new disconnected component of the surveyed PGFPlots path.
-- No candidate interval may cross this break.
function M.add_path_break(id)
  local label = labels[validated_number(id, 0, "path label id", 1, nil, true)]
  if label then
    label.path_points[#label.path_points + 1] = {break_before = true}
  end
end

--- Record one surveyed path vertex and PGFPlots' preceding segment length.
-- The latter is the metric used by \pgfplotspointplotattime, which preserves
-- the documented normalized path-position convention.
function M.add_path_point(id, x, y, segment_length)
  local label = labels[validated_number(id, 0, "path label id", 1, nil, true)]
  if not label then return end
  local points = label.path_points
  local previous = points[#points]
  local length = validated_number(segment_length, 0, "path segment length", 0)
  if not previous or previous.break_before then length = 0 end
  label.path_length = (label.path_length or 0) + length
  points[#points + 1] = {
    x = validated_number(x, 0, "path x coordinate"),
    y = validated_number(y, 0, "path y coordinate"),
    distance = label.path_length,
  }
end

local function clip_segment_to_axis(a, b)
  if config.allow_outside then return 0, 1 end
  if not axis_rect then return nil end
  local dx, dy = b.x - a.x, b.y - a.y
  local start, finish = 0, 1
  local function cut(p, q)
    if math.abs(p) < 1e-12 then return q >= 0 end
    local fraction = q / p
    if p < 0 then
      if fraction > finish then return false end
      start = math.max(start, fraction)
    else
      if fraction < start then return false end
      finish = math.min(finish, fraction)
    end
    return true
  end
  if not cut(-dx, a.x - axis_rect.left)
      or not cut(dx, axis_rect.right - a.x)
      or not cut(-dy, a.y - axis_rect.bottom)
      or not cut(dy, axis_rect.top - a.y)
      or finish <= start then
    return nil
  end
  return start, finish
end

local function visible_path_intervals(label, min_pos, max_pos)
  local intervals = {}
  local total = label.path_length or 0
  if total <= 0 then return intervals end
  local previous
  for _, point in ipairs(label.path_points) do
    if point.break_before then
      previous = nil
    else
      if not previous then point.continues_visible = false end
      if previous and point.distance > previous.distance then
        local first, last = clip_segment_to_axis(previous, point)
        if first then
          local delta = point.distance - previous.distance
          local lo = math.max(min_pos, (previous.distance + first * delta) / total)
          local hi = math.min(max_pos, (previous.distance + last * delta) / total)
          if hi > lo + 1e-12 then
            local prior = intervals[#intervals]
            if prior and previous.continues_visible
                and lo <= prior[2] + 1e-10 then
              prior[2] = math.max(prior[2], hi)
            else
              intervals[#intervals + 1] = {lo, hi}
            end
            point.continues_visible = true
          else
            point.continues_visible = false
          end
        else
          point.continues_visible = false
        end
      end
      previous = point
    end
  end
  return intervals
end

local function positions_on_visible_path(intervals, preferred, n, strategy)
  local total = 0
  for _, interval in ipairs(intervals) do
    total = total + interval[2] - interval[1]
  end
  if total <= 0 then return {} end

  local preferred_distance, elapsed = 0, 0
  local best_distance = math.huge
  for _, interval in ipairs(intervals) do
    local projected = clamp(preferred, interval[1], interval[2])
    local distance = math.abs(projected - preferred)
    if distance < best_distance then
      best_distance = distance
      preferred_distance = elapsed + projected - interval[1]
    end
    elapsed = elapsed + interval[2] - interval[1]
  end
  local visible_preferred = preferred_distance / total
  local fractions
  if strategy == "uniform" then
    fractions = candidate_positions_uniform(visible_preferred, 0, 1, n)
  elseif strategy == "adaptive" then
    fractions = candidate_positions_adaptive(visible_preferred, 0, 1, n)
  else
    fractions = candidate_positions_around_preferred(visible_preferred, 0, 1, n)
  end

  local positions = {}
  for _, fraction in ipairs(fractions) do
    local target = fraction * total
    local offset = 0
    for index, interval in ipairs(intervals) do
      local span = interval[2] - interval[1]
      if target <= offset + span or index == #intervals then
        positions[#positions + 1] = {
          pos = interval[1] + clamp(target - offset, 0, span),
          component = index,
        }
        break
      end
      offset = offset + span
    end
  end
  return positions
end

candidate_positions_uniform = function(preferred, min_pos, max_pos, n)
  local positions = {}
  if n <= 1 then
    return { clamp(preferred, min_pos, max_pos) }
  end
  for i = 0, n - 1 do
    positions[#positions + 1] = min_pos + (max_pos - min_pos) * i / (n - 1)
  end
  return positions
end

candidate_positions_around_preferred = function(preferred, min_pos, max_pos, n)
  local positions = {}
  local seen = {}
  local function add(p)
    p = clamp(p, min_pos, max_pos)
    local key = string.format("%.8f", p)
    if not seen[key] and #positions < n then
      positions[#positions + 1] = p
      seen[key] = true
    end
  end

  preferred = clamp(preferred, min_pos, max_pos)
  add(preferred)
  if n <= 1 then return positions end

  local step = (max_pos - min_pos) / math.max(1, n - 1)
  for k = 1, 4 * n do
    add(preferred - k * step)
    add(preferred + k * step)
    if #positions >= n then break end
  end
  return positions
end

candidate_positions_adaptive = function(preferred, min_pos, max_pos, n)
  -- The adaptive strategy keeps a dense cluster around the preferred position
  -- and a coarse coverage of the whole admissible interval.  It is still a
  -- deterministic finite set, which is important for reproducible TeX builds.
  if n <= 3 then
    return candidate_positions_around_preferred(preferred, min_pos, max_pos, n)
  end
  local positions = {}
  local seen = {}
  local function add(p)
    p = clamp(p, min_pos, max_pos)
    local key = string.format("%.8f", p)
    if not seen[key] and #positions < n then
      positions[#positions + 1] = p
      seen[key] = true
    end
  end

  preferred = clamp(preferred, min_pos, max_pos)
  add(preferred)
  local local_count = math.floor(0.6 * n)
  local global_count = n - local_count
  local local_span = 0.25 * (max_pos - min_pos)
  local local_min = clamp(preferred - local_span, min_pos, max_pos)
  local local_max = clamp(preferred + local_span, min_pos, max_pos)

  for _, p in ipairs(candidate_positions_around_preferred(preferred, local_min, local_max, local_count)) do add(p) end
  for _, p in ipairs(candidate_positions_uniform(preferred, min_pos, max_pos, global_count + 2)) do add(p) end
  for _, p in ipairs(candidate_positions_around_preferred(preferred, min_pos, max_pos, n)) do add(p) end
  return positions
end

--- Emit TeX callbacks that sample candidate positions on the current path.
-- @tparam number id Label identifier.
-- @tparam number preferred Preferred normalized path position.
-- @tparam number min_pos Smallest admissible normalized position.
-- @tparam number max_pos Largest admissible normalized position.
-- @tparam number n Requested number of candidates.
-- @tparam string strategy `uniform`, `around-preferred`, or `adaptive`.
function M.emit_candidate_positions(id, preferred, min_pos, max_pos, n, strategy)
  id = validated_number(id, 0, "candidate label id", 1, nil, true)
  preferred = validated_number(preferred, 0.5,
    "preferred position", 0, 1)
  min_pos = validated_number(min_pos, 0.05, "minimum position", 0, 1)
  max_pos = validated_number(max_pos, 0.95, "maximum position", 0, 1)
  if max_pos < min_pos then
    min_pos, max_pos = max_pos, min_pos
  end
  n = validated_number(n, 31, "candidate count", 2, nil, true)
  strategy = strategy or "around-preferred"

  local positions
  if strategy == "uniform" then
    positions = candidate_positions_uniform(preferred, min_pos, max_pos, n)
  elseif strategy == "adaptive" then
    positions = candidate_positions_adaptive(preferred, min_pos, max_pos, n)
  else
    if strategy ~= "around-preferred" and strategy ~= "around preferred" then
      warn("unknown candidate strategy `" .. tostring(strategy)
        .. "'; using around-preferred")
    end
    positions = candidate_positions_around_preferred(preferred, min_pos, max_pos, n)
  end

  log(2, string.format("emit %d candidate sample(s) for label #%d using strategy=%s", #positions, id, strategy))
  for _, pos in ipairs(positions) do
    tex.sprint(string.format(
      "\\csname pgfplotsautonode@samplecandidate\\endcsname{%s}{0}",
      tex_number(pos)))
  end
end

--- Sample only visible portions of the surveyed plot polyline.
-- The path's normalized positions retain PGFPlots' full-path meaning.  If the
-- surveyed stream is unavailable, fall back to the original point-at-time
-- sampler; the hard axis-border check still applies to every candidate.
function M.emit_visible_candidate_positions(id, preferred, min_pos, max_pos,
    n, strategy)
  id = validated_number(id, 0, "candidate label id", 1, nil, true)
  preferred = validated_number(preferred, 0.5, "preferred position", 0, 1)
  min_pos = validated_number(min_pos, 0, "minimum position", 0, 1)
  max_pos = validated_number(max_pos, 1, "maximum position", 0, 1)
  if max_pos < min_pos then min_pos, max_pos = max_pos, min_pos end
  n = validated_number(n, 51, "candidate count", 2, nil, true)
  strategy = strategy or "around-preferred"
  if strategy == "around preferred" then strategy = "around-preferred" end
  if strategy ~= "uniform" and strategy ~= "adaptive"
      and strategy ~= "around-preferred" then
    warn("unknown candidate strategy `" .. tostring(strategy)
      .. "'; using around-preferred")
    strategy = "around-preferred"
  end

  local label = labels[id]
  if not label then return end
  if not label.path_length or label.path_length <= 0 then
    warn(string.format(
      "label #%d has no surveyed path metric; using full-path candidate sampling",
      id))
    M.emit_candidate_positions(id, preferred, min_pos, max_pos, n, strategy)
    return
  end
  local intervals, positions
  if max_pos == min_pos then
    -- A fixed label has one admissible path position, not a zero-length
    -- interval to be discarded by the segment clipper.
    intervals = visible_path_intervals(label, 0, 1)
    positions = {}
    for index, interval in ipairs(intervals) do
      if min_pos >= interval[1] - 1e-10
          and min_pos <= interval[2] + 1e-10 then
        positions[1] = {pos = min_pos, component = index}
        break
      end
    end
  else
    intervals = visible_path_intervals(label, min_pos, max_pos)
    positions = positions_on_visible_path(intervals, preferred, n, strategy)
  end
  log(1, string.format(
    "label #%d: %d visible path component(s), %d candidate sample(s)",
    id, #intervals, #positions))
  for _, item in ipairs(positions) do
    -- PGFPlots' point-at-time interpolator can fail at exactly 0 or 1 on a
    -- disconnected path. The surveyed endpoint is already in canvas
    -- coordinates, so use it directly without changing the requested position.
    local endpoint
    if item.pos <= 1e-12 then
      for _, point in ipairs(label.path_points) do
        if not point.break_before then
          endpoint = point
          break
        end
      end
    elseif item.pos >= 1 - 1e-12 then
      for index = #label.path_points, 1, -1 do
        local point = label.path_points[index]
        if not point.break_before then
          endpoint = point
          break
        end
      end
    end
    if endpoint then
      M.add_candidate(id, item.pos, endpoint.x, endpoint.y, item.component)
    else
      tex.sprint(string.format(
        "\\csname pgfplotsautonode@samplecandidate\\endcsname{%s}{%d}",
        tex_number(item.pos), item.component))
    end
  end
end

--- Solve the current label assignment without emitting drawing commands.
-- @treturn table Labels ordered by identifier, including their chosen
-- candidates and visibility state.
function M.solve()
  local list = ordered_labels()
  log(1, string.format("solve: %d label(s), algorithm=%s", #list, config.algorithm))

  if #list > 0 and not axis_rect and not config.allow_outside then
    package_error("visible axis rectangle is unavailable; automatic labels cannot be placed")
  end

  -- A label with no inside candidate is not part of the assignment problem.
  -- Overlap policies may relax label-to-label collisions, never the axis border.
  local active = prepare_all_labels(list)

  if config.algorithm == "greedy" then
    solve_greedy(active)
  elseif config.algorithm == "local-search" then
    solve_local_search(active)
  elseif config.algorithm == "exact-small" then
    exact_search(active)
  else
    solve_repair(active)
  end

  enforce_visibility(list)
  local status = handle_final_failures(list)
  log(1, string.format(
    "final status: overlaps=%d outside=%d hidden=%d",
    status.overlaps, status.outside, status.hidden
  ))

  for _, label in ipairs(ordered_labels_by_id()) do
    if label.hidden then
      log(1, string.format("final label #%d: hidden", label.id))
    elseif label.choice then
      log(1, string.format(
        "final label #%d: pos=%s center=(%s,%s) angle=%s",
        label.id,
        fmt(label.choice.pos),
        fmt(label.choice.x),
        fmt(label.choice.y),
        fmt(label.choice.angle)
      ))
    end
  end

  return ordered_labels_by_id()
end

local function emit_candidate_debug(list)
  if not config.show_candidates and not config.show_rejected_candidates then
    return
  end
  for _, label in ipairs(list) do
    for _, candidate in ipairs(label.candidates or {}) do
      if candidate.valid or config.show_rejected_candidates then
        local state = candidate.valid and "valid" or "rejected"
        tex.sprint(string.format(
          "\\pgfplotsautonodedrawcandidate{%s}{%s}{%s}",
          state,
          tex_number(candidate.x or candidate.base_x),
          tex_number(candidate.y or candidate.base_y)
        ))
      end
    end
  end
end

local function emit_box_debug(list)
  if not config.show_bounding_boxes then return end
  for _, label in ipairs(list) do
    if label.choice and not label.hidden then
      local box = label.choice.box.aabb
      tex.sprint(string.format(
        "\\pgfplotsautonodedrawbbox{%d}{%s}{%s}{%s}{%s}",
        label.id,
        tex_number(box.xmin),
        tex_number(box.ymin),
        tex_number(box.xmax),
        tex_number(box.ymax)
      ))
    end
  end
end

--- Solve the current label assignment and emit final TeX drawing callbacks.
-- Debug candidates and bounding boxes are emitted when enabled. Axis-local
-- state is reset after emission.
function M.solve_and_emit()
  local list = M.solve()

  emit_candidate_debug(list)

  for _, label in ipairs(list) do
    local choice = label.choice
    if choice and not label.hidden then
      local angle = label.sloped and choice.angle or 0.0
      tex.sprint(string.format(
        "\\pgfplotsautonodedraw{%d}{%s}{%s}{%s}",
        label.id,
        tex_number(choice.x),
        tex_number(choice.y),
        tex_number(angle)
      ))
    end
  end

  emit_box_debug(list)
  M.reset()
end

return M
