-- Deterministic regression tests for the numerical autonode backend.
--
-- Run with texlua from the repository root.  These tests deliberately avoid
-- PGFPlots and CoolProp: they exercise candidate preparation and assignment as
-- a standalone Lua component, while the document tests cover the TeX bridge.

if kpse and kpse.set_program_name then
  pcall(kpse.set_program_name, "luatex")
end
local module_path = nil
if kpse and kpse.find_file then
  local ok, result = pcall(kpse.find_file, "pgfplots-autonode.lua", "lua")
  if ok then module_path = result end
end
module_path = module_path or "pgfplots-autonode.lua"
local autonode = dofile(module_path)

local function assert_equal(actual, expected, message)
  if actual ~= expected then
    error(string.format("%s: expected %s, got %s",
      message, tostring(expected), tostring(actual)), 2)
  end
end

local function assert_close(actual, expected, tolerance, message)
  if math.abs(actual - expected) > tolerance then
    error(string.format("%s: expected %.8g, got %.8g",
      message, expected, actual), 2)
  end
end

local function add_two_choice_label(id, preferred, priority)
  autonode.add_label(
    id, 20, 8, preferred, 0, 1,
    false, false, 8, 1000, priority
  )
  autonode.add_candidate(id, 0.25, 25, 50)
  autonode.add_candidate(id, 0.75, 75, 50)
end

local function assert_collision_free_algorithm(algorithm)
  autonode.configure({
    algorithm = algorithm,
    bbox_mode = "axis-aligned",
    failure_mode = "error",
    border_margin = 2,
    overlap_tolerance = 0.2,
    max_iterations = 80,
    exact_max_labels = 10,
    exact_max_states = 50000,
  })
  autonode.reset()
  autonode.set_axis_rect(0, 100, 0, 100)
  add_two_choice_label(1, 0.25, 10)
  add_two_choice_label(2, 0.25, 0)

  local labels = autonode.solve()
  assert_equal(#labels, 2, algorithm .. " label count")
  assert(labels[1].choice ~= nil, algorithm .. " must place label 1")
  assert(labels[2].choice ~= nil, algorithm .. " must place label 2")
  assert(labels[1].choice.pos ~= labels[2].choice.pos,
    algorithm .. " must separate colliding labels")
  assert(not labels[1].hidden and not labels[2].hidden,
    algorithm .. " must keep both feasible labels")
end

for _, algorithm in ipairs({"greedy", "repair", "local-search", "exact-small"}) do
  assert_collision_free_algorithm(algorithm)
end

-- When no collision-free assignment exists, the documented failure policy
-- must preserve the higher-priority label and hide the lower-priority one.
autonode.configure({
  algorithm = "repair",
  bbox_mode = "oriented",
  failure_mode = "hide-low-priority",
  border_margin = 0,
})
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 30, 10, 0.5, 0, 1, true, false, 8, 1000, 20)
autonode.add_label(2, 30, 10, 0.5, 0, 1, true, false, 8, 1000, 1)
for id = 1, 2 do
  autonode.add_candidate(id, 0.4, 40, 50)
  autonode.add_candidate(id, 0.6, 60, 50)
end

local priority_labels = autonode.solve()
assert(not priority_labels[1].hidden, "high-priority label must remain visible")
assert(priority_labels[2].hidden, "low-priority label must be hidden first")

-- The public overlap weight must affect the optimizer.  With zero weight the
-- preferred coincident candidates are retained and the failure policy hides
-- the later label.  With a large weight, the second label moves to its
-- non-overlapping alternative and both remain visible.
local function solve_weighted_overlap(weight)
  autonode.configure({
    algorithm = "local-search",
    bbox_mode = "axis-aligned",
    failure_mode = "hide-low-priority",
    border_margin = 0,
    overlap_tolerance = 0,
  })
  autonode.reset()
  autonode.set_axis_rect(0, 100, 0, 100)
  autonode.add_label(1, 20, 10, 0.5, 0, 0,
    false, false, 100, weight, 0)
  autonode.add_candidate(1, 0.5, 50, 50)
  autonode.add_label(2, 20, 10, 0.5, 0, 0,
    false, false, 100, weight, 0)
  autonode.add_candidate(2, 0.5, 50, 50)
  autonode.add_candidate(2, 0.9, 80, 50)
  return autonode.solve()
end

local unweighted = solve_weighted_overlap(0)
assert_close(unweighted[2].choice.pos, 0.5, 1e-12,
  "zero overlap weight must preserve the preferred candidate")
assert(unweighted[2].hidden,
  "the failure policy must hide the unresolved unweighted conflict")

local weighted = solve_weighted_overlap(1000)
assert_close(weighted[2].choice.pos, 0.9, 1e-12,
  "large overlap weight must select the separated candidate")
assert(not weighted[1].hidden and not weighted[2].hidden,
  "weighted separation must keep both labels visible")

-- Candidate boxes which cross the visible plot border must be rejected while
-- an interior alternative remains usable.
autonode.configure({
  algorithm = "greedy",
  bbox_mode = "axis-aligned",
  failure_mode = "error",
  border_margin = 2,
})
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 20, 8, 0.1, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_candidate(1, 0.1, 5, 50)
autonode.add_candidate(1, 0.8, 50, 50)
local border_labels = autonode.solve()
assert_equal(#border_labels[1].invalid_candidates, 1,
  "border test rejected-candidate count")
assert_equal(#border_labels[1].valid_candidates, 1,
  "border test valid-candidate count")
assert_close(border_labels[1].choice.pos, 0.8, 1e-12,
  "border test must select the interior candidate")

-- TikZ node options can make the actual box asymmetric about its placement
-- coordinate.  A west anchor must be checked using its measured offsets,
-- not a box centred on the path point.
autonode.configure({
  algorithm = "greedy",
  failure_mode = "error",
  allow_outside = false,
  border_margin = 0,
})
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 0, 0, 0.9, 0, 0,
  false, false, 8, 1000, 0)
autonode.set_label_geometry(1, 0, 30, -5, 5)
autonode.add_candidate(1, 0.9, 80, 50)
autonode.add_candidate(1, 0.6, 60, 50)
local anchored = autonode.solve()[1]
assert_equal(#anchored.invalid_candidates, 1,
  "west-anchored node must reject its overflowing candidate")
assert_close(anchored.choice.pos, 0.6, 1e-12,
  "west-anchored node must move to the interior")

-- Visible-path sampling must clip the surveyed polyline before allocating
-- samples.  Even when two visible pieces have adjacent normalized positions,
-- a PGFPlots path break must keep their component identifiers distinct.
local emitted = {}
tex = {sprint = function(value) emitted[#emitted + 1] = value end}
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 10, 5, 0.5, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_path_point(1, -100, 50, 0)
autonode.add_path_point(1, 20, 50, 120)
autonode.add_path_break(1)
autonode.add_path_point(1, 80, 50, 0)
autonode.add_path_point(1, 200, 50, 120)
autonode.emit_visible_candidate_positions(1, 0.5, 0, 1, 9, "uniform")
assert_equal(#emitted, 9, "visible-path candidate count")
local components = {}
for _, callback in ipairs(emitted) do
  local position, component = callback:match("samplecandidate\\endcsname{([%d%.]+)}{(%d+)}")
  assert(position and component, "visible-path callback syntax")
  position, component = tonumber(position), tonumber(component)
  components[component] = true
  assert(position >= 5 / 12 - 1e-6 and position <= 7 / 12 + 1e-6,
    "candidate must lie on a visible piece")
end
assert(components[1] and components[2],
  "visible disconnected pieces must retain separate components")

-- Exact path endpoints use surveyed canvas vertices. PGFPlots' point-at-time
-- interpolation is not reliable at 0 or 1 for some disconnected log-axis
-- curves; the endpoint coordinate and normalized position must stay exact.
emitted = {}
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 10, 5, 0.5, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_path_point(1, 10, 50, 0)
autonode.add_path_point(1, 30, 50, 20)
autonode.add_path_break(1)
autonode.add_path_point(1, 70, 50, 0)
autonode.add_path_point(1, 90, 50, 20)
autonode.emit_visible_candidate_positions(1, 0.5, 0, 1, 3, "uniform")
assert_equal(#emitted, 1, "only the interior needs TeX interpolation")
local endpoint_candidates = autonode.solve()[1].candidates
local saw_start, saw_end = false, false
for _, candidate in ipairs(endpoint_candidates) do
  if candidate.pos == 0 and candidate.base_x == 10 then saw_start = true end
  if candidate.pos == 1 and candidate.base_x == 90 then saw_end = true end
end
assert(saw_start and saw_end, "surveyed endpoints must retain exact positions")

-- A connected source polyline may also leave the viewport and later re-enter
-- it.  Do not bridge the hidden excursion when computing local tangents.
emitted = {}
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 10, 5, 0.5, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_path_point(1, 10, 50, 0)
autonode.add_path_point(1, 20, 50, 10)
autonode.add_path_point(1, 50, 150, 104.403)
autonode.add_path_point(1, 80, 50, 104.403)
autonode.add_path_point(1, 90, 50, 10)
autonode.emit_visible_candidate_positions(1, 0.5, 0, 1, 9, "uniform")
components = {}
for _, callback in ipairs(emitted) do
  local component = callback:match("samplecandidate\\endcsname{[%d%.]+}{(%d+)}")
  components[tonumber(component)] = true
end
assert(components[1] and components[2],
  "off-axis excursions must split visible candidate components")

-- A wholly off-axis path emits no candidates, even under an overlap policy
-- that would otherwise tolerate an imperfect placement.
emitted = {}
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 10, 5, 0.5, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_path_point(1, -100, 50, 0)
autonode.add_path_point(1, -20, 50, 80)
autonode.emit_visible_candidate_positions(1, 0.5, 0, 1, 9, "uniform")
assert_equal(#emitted, 0, "off-axis path must emit no samples")

-- A deliberately fixed path fraction remains admissible when it falls on a
-- visible segment; the zero-width permitted range must not be discarded.
emitted = {}
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 10, 5, 0.25, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_path_point(1, 10, 50, 0)
autonode.add_path_point(1, 90, 50, 80)
autonode.emit_visible_candidate_positions(1, 0.25, 0.25, 0.25, 9, "uniform")
assert_equal(#emitted, 1, "fixed visible position must emit exactly one sample")

-- Visibility is a hard constraint even when an overlap failure policy permits
-- imperfect assignments.  An off-axis curve must never produce a visible
-- fallback label, and it must not prevent another label from being placed.
for _, algorithm in ipairs({"greedy", "repair", "local-search", "exact-small"}) do
  for _, failure_mode in ipairs({
      "warn", "hide-low-priority", "allow-minimal-overlap",
    }) do
    autonode.configure({
      algorithm = algorithm,
      failure_mode = failure_mode,
      allow_outside = false,
      border_margin = 2,
    })
    autonode.reset()
    autonode.set_axis_rect(0, 100, 0, 100)
    autonode.add_label(1, 20, 8, 0.5, 0, 0,
      false, false, 8, 1000, 0)
    autonode.add_candidate(1, 0.5, -20, 50)
    autonode.add_label(2, 20, 8, 0.5, 0, 0,
      false, false, 8, 1000, 0)
    autonode.add_candidate(2, 0.5, 50, 50)
    local result = autonode.solve()
    assert(result[1].hidden and result[1].choice == nil,
      algorithm .. "/" .. failure_mode .. " must hide the outside label")
    assert(not result[2].hidden and result[2].choice ~= nil,
      algorithm .. "/" .. failure_mode .. " must keep the inside label")
  end
end

autonode.configure({
  algorithm = "repair",
  failure_mode = "error",
  allow_outside = false,
})
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 20, 8, 0.5, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_candidate(1, 0.5, -20, 50)
local ok, err = pcall(autonode.solve)
assert(not ok and tostring(err):find("no valid candidate", 1, true),
  "error mode must reject a label with no inside candidate")

-- Explicit opt-out remains available to authors who intentionally want labels
-- outside the plot rectangle.
autonode.configure({
  algorithm = "greedy",
  failure_mode = "error",
  allow_outside = true,
})
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 20, 8, 0.5, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_candidate(1, 0.5, -20, 50)
local opted_out = autonode.solve()[1]
assert(not opted_out.hidden and opted_out.choice ~= nil,
  "allow_outside must explicitly permit an outside label")

-- Missing plot geometry must fail closed instead of silently disabling the
-- border check.
autonode.configure({allow_outside = false})
autonode.reset()
autonode.add_label(1, 20, 8, 0.5, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_candidate(1, 0.5, 50, 50)
ok, err = pcall(autonode.solve)
assert(not ok and tostring(err):find("rectangle is unavailable", 1, true),
  "missing axis rectangle must not allow unconstrained placement")

-- Sloped labels use canvas-space tangents and are turned upright unless the
-- caller explicitly permits upside-down text.
local function solve_reversed_tangent(allow_upside_down)
  autonode.configure({
    algorithm = "greedy",
    bbox_mode = "oriented",
    failure_mode = "error",
    border_margin = 0,
    allow_outside = true,
  })
  autonode.reset()
  autonode.set_axis_rect(0, 100, 0, 100)
  autonode.add_label(1, 12, 6, 0.25, -6, 0,
    true, allow_upside_down, 8, 1000, 0)
  autonode.add_candidate(1, 0.25, 80, 50)
  autonode.add_candidate(1, 0.75, 20, 50)
  return autonode.solve()[1]
end

local upright = solve_reversed_tangent(false)
assert_close(upright.choice.angle, 0, 1e-12,
  "reversed tangent must be normalized upright")
assert_close(upright.choice.y, 44, 1e-12,
  "negative normal shift must select the opposite side")
local inverted = solve_reversed_tangent(true)
assert_close(math.abs(inverted.choice.angle), 180, 1e-12,
  "allow-upside-down must preserve the reversed tangent")

-- Two parallel thin labels can have overlapping axis-aligned bounding boxes
-- while their oriented rectangles remain disjoint.  Exercise both collision
-- models with exactly the same candidates.
local function solve_diagonal_pair(mode)
  autonode.configure({
    algorithm = "greedy",
    bbox_mode = mode,
    failure_mode = "hide-low-priority",
    border_margin = 0,
    allow_outside = true,
  })
  autonode.reset()
  autonode.set_axis_rect(0, 100, 0, 100)
  autonode.add_label(1, 40, 4, 0.4, 0, 0,
    true, false, 8, 1000, 0)
  autonode.add_candidate(1, 0.4, 39, 39)
  autonode.add_candidate(1, 0.6, 41, 41)
  autonode.add_label(2, 40, 4, 0.4, 0, 0,
    true, false, 8, 1000, 0)
  autonode.add_candidate(2, 0.4, 49, 29)
  autonode.add_candidate(2, 0.6, 51, 31)
  return autonode.solve()
end

local axis_aligned = solve_diagonal_pair("axis-aligned")
assert(axis_aligned[2].hidden,
  "axis-aligned boxes must detect the conservative diagonal conflict")
local oriented = solve_diagonal_pair("oriented")
assert(not oriented[1].hidden and not oriented[2].hidden,
  "oriented boxes must preserve disjoint diagonal labels")

-- Reset is the axis-isolation boundary: no labels from an earlier axis may
-- survive into the next solve.
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
autonode.add_label(1, 10, 5, 0.5, 0, 0,
  false, false, 8, 1000, 0)
autonode.add_candidate(1, 0.5, 50, 50)
autonode.reset()
autonode.set_axis_rect(0, 100, 0, 100)
assert_equal(#autonode.solve(), 0,
  "reset must isolate consecutive axes")

io.write("pgfplots-autonode Lua regression tests passed\n")
