-- hitboxes_lib API implementation
-- Provides hitbox registration and hit detection for combat systems

-- Verbosity level for debugging (0 = off, 1 = basic, 2 = detailed, 3 = verbose, 4 = very verbose with math details)
local verbose = 0

-- Storage for registered hitbox groups
hitboxes_lib.hitbox_groups = {}
local hitbox_groups = hitboxes_lib.hitbox_groups

-- Helper function to normalize a box definition to 8 vertices
local function normalize_box(box)
	if #box == 8 then
		-- Already 8 vertices
		return box
	elseif box.x_min and box.x_max then
		-- Min/max format, convert to 8 vertices
		local x_min, y_min, z_min = box.x_min, box.y_min, box.z_min
		local x_max, y_max, z_max = box.x_max, box.y_max, box.z_max
		
		return {
			{x = x_min, y = y_min, z = z_min}, -- 1
			{x = x_max, y = y_min, z = z_min}, -- 2
			{x = x_max, y = y_min, z = z_max}, -- 3
			{x = x_min, y = y_min, z = z_max}, -- 4
			{x = x_min, y = y_max, z = z_min}, -- 5
			{x = x_max, y = y_max, z = z_min}, -- 6
			{x = x_max, y = y_max, z = z_max}, -- 7
			{x = x_min, y = y_max, z = z_max}, -- 8
		}
	else
		error("Invalid box format")
	end
end

-- Helper function to rotate a point around Y axis (yaw)
local function rotate_point(point, yaw)
	local cos_yaw = math.cos(yaw)
	local sin_yaw = math.sin(yaw)
	
	return {
		x = point.x * cos_yaw - point.z * sin_yaw,
		y = point.y,
		z = point.x * sin_yaw + point.z * cos_yaw
	}
end

-- Helper function to add two vectors
local function vector_add(a, b)
	return {x = a.x + b.x, y = a.y + b.y, z = a.z + b.z}
end

-- Helper function to get bounding box from 8 vertices
local function get_aabb_from_vertices(vertices)
	local min_x, min_y, min_z = math.huge, math.huge, math.huge
	local max_x, max_y, max_z = -math.huge, -math.huge, -math.huge
	
	for _, v in ipairs(vertices) do
		min_x = math.min(min_x, v.x)
		min_y = math.min(min_y, v.y)
		min_z = math.min(min_z, v.z)
		max_x = math.max(max_x, v.x)
		max_y = math.max(max_y, v.y)
		max_z = math.max(max_z, v.z)
	end
	
	return {
		x_min = min_x, y_min = min_y, z_min = min_z,
		x_max = max_x, y_max = max_y, z_max = max_z
	}
end

-- Helper function for ray-box intersection (AABB only, used for quick broadphase)
local function ray_intersects_aabb(ray_origin, ray_dir, aabb, debug_name)
	-- Handle zero or near-zero direction components
	if math.abs(ray_dir.x) < 0.000001 then ray_dir.x = 0.000001 end
	if math.abs(ray_dir.y) < 0.000001 then ray_dir.y = 0.000001 end
	if math.abs(ray_dir.z) < 0.000001 then ray_dir.z = 0.000001 end
	
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]       Ray-AABB test for '%s':", debug_name))
		print(string.format("[hitboxes_lib]         Ray: origin=(%.2f,%.2f,%.2f) dir=(%.2f,%.2f,%.2f)",
			ray_origin.x, ray_origin.y, ray_origin.z, ray_dir.x, ray_dir.y, ray_dir.z))
		print(string.format("[hitboxes_lib]         AABB: (%.2f,%.2f,%.2f) to (%.2f,%.2f,%.2f)",
			aabb.x_min, aabb.y_min, aabb.z_min, aabb.x_max, aabb.y_max, aabb.z_max))
	end
	
	-- Calculate intersection distances for each axis
	local tx_min = (aabb.x_min - ray_origin.x) / ray_dir.x
	local tx_max = (aabb.x_max - ray_origin.x) / ray_dir.x
	local x_axis = nil
	
	if tx_min > tx_max then
		tx_min, tx_max = tx_max, tx_min
		x_axis = "x+"
	else
		x_axis = "x-"
	end
	
	local tmin = tx_min
	local tmax = tx_max
	local hit_axis = x_axis
	
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]         X: tmin=%.4f, tmax=%.4f", tmin, tmax))
	end
	
	local ty_min = (aabb.y_min - ray_origin.y) / ray_dir.y
	local ty_max = (aabb.y_max - ray_origin.y) / ray_dir.y
	local y_axis = nil
	
	if ty_min > ty_max then
		ty_min, ty_max = ty_max, ty_min
		y_axis = "y+"
	else
		y_axis = "y-"
	end
	
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]         Y: tymin=%.4f, tymax=%.4f", ty_min, ty_max))
	end
	
	if (tmin > ty_max) or (ty_min > tmax) then
		if verbose > 3 and debug_name then
			print(string.format("[hitboxes_lib]         FAIL: X-Y ranges don't overlap (tmin=%.4f > tymax=%.4f or tymin=%.4f > tmax=%.4f)",
				tmin, ty_max, ty_min, tmax))
		end
		return false, nil, nil
	end
	
	if ty_min > tmin then
		tmin = ty_min
		hit_axis = y_axis
	end
	if ty_max < tmax then tmax = ty_max end
	
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]         After Y merge: tmin=%.4f, tmax=%.4f", tmin, tmax))
	end
	
	local tz_min = (aabb.z_min - ray_origin.z) / ray_dir.z
	local tz_max = (aabb.z_max - ray_origin.z) / ray_dir.z
	local z_axis = nil
	
	if tz_min > tz_max then
		tz_min, tz_max = tz_max, tz_min
		z_axis = "z+"
	else
		z_axis = "z-"
	end
	
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]         Z: tzmin=%.4f, tzmax=%.4f", tz_min, tz_max))
	end
	
	if (tmin > tz_max) or (tz_min > tmax) then
		if verbose > 3 and debug_name then
			print(string.format("[hitboxes_lib]         FAIL: X-Y-Z ranges don't overlap (tmin=%.4f > tzmax=%.4f or tzmin=%.4f > tmax=%.4f)",
				tmin, tz_max, tz_min, tmax))
		end
		return false, nil, nil
	end
	
	if tz_min > tmin then
		tmin = tz_min
		hit_axis = z_axis
	end
	if tz_max < tmax then tmax = tz_max end
	
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]         After Z merge: tmin=%.4f, tmax=%.4f", tmin, tmax))
	end
	
	if tmin < 0 then
		if verbose > 3 and debug_name then
			print(string.format("[hitboxes_lib]         FAIL: tmin < 0 (behind ray origin)"))
		end
		return false, nil, nil
	end
	
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]         SUCCESS: Hit at t=%.4f on axis %s", tmin, hit_axis))
	end
	
	return true, tmin, hit_axis
end

-- Helper function to test if a point is inside an OBB (oriented bounding box)
-- Uses the original unrotated box vertices to determine bounds
local function point_inside_obb(point, box_center, box_half_extents)
	-- Check if point is within the box bounds in local coordinates
	local dx = math.abs(point.x - box_center.x)
	local dy = math.abs(point.y - box_center.y)
	local dz = math.abs(point.z - box_center.z)
	
	return dx <= box_half_extents.x and 
	       dy <= box_half_extents.y and 
	       dz <= box_half_extents.z
end

-- Helper function for ray-OBB (Oriented Bounding Box) intersection
-- This tests the ray against the actual rotated box, not its axis-aligned bounding box
-- Returns: intersects (bool), distance (number), hit_axis (string), hit_pos_local (vector)
local function ray_intersects_obb(ray_origin, ray_dir, vertices, original_vertices, box_position, box_yaw, debug_name)
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]       Ray-OBB test for '%s':", debug_name))
	end
	
	-- Define the 6 faces of the box using vertex indices (counter-clockwise winding)
	-- Bottom face (y_min): vertices 1,2,3,4
	-- Top face (y_max): vertices 5,6,7,8
	-- Front face (z_min): vertices 1,2,6,5
	-- Back face (z_max): vertices 4,3,7,8
	-- Left face (x_min): vertices 1,4,8,5
	-- Right face (x_max): vertices 2,3,7,6
	local faces = {
		{name = "y-", indices = {1, 2, 3, 4}},  -- bottom
		{name = "y+", indices = {5, 6, 7, 8}},  -- top
		{name = "z-", indices = {1, 2, 6, 5}},  -- front
		{name = "z+", indices = {4, 3, 7, 8}},  -- back
		{name = "x-", indices = {1, 4, 8, 5}},  -- left
		{name = "x+", indices = {2, 3, 7, 6}},  -- right
	}
	
	local closest_hit = nil
	local closest_distance = math.huge
	local closest_axis = nil
	local closest_hit_local = nil
	
	for _, face in ipairs(faces) do
		-- Get three vertices to define the plane (we use first 3 of the 4 vertices)
		local v1 = vertices[face.indices[1]]
		local v2 = vertices[face.indices[2]]
		local v3 = vertices[face.indices[3]]
		
		-- Calculate plane normal using cross product
		local edge1 = {x = v2.x - v1.x, y = v2.y - v1.y, z = v2.z - v1.z}
		local edge2 = {x = v3.x - v1.x, y = v3.y - v1.y, z = v3.z - v1.z}
		
		-- Normal = edge1 × edge2
		local normal = {
			x = edge1.y * edge2.z - edge1.z * edge2.y,
			y = edge1.z * edge2.x - edge1.x * edge2.z,
			z = edge1.x * edge2.y - edge1.y * edge2.x
		}
		
		-- Normalize the normal vector
		local normal_length = math.sqrt(normal.x^2 + normal.y^2 + normal.z^2)
		if normal_length < 0.000001 then
			-- Degenerate face, skip
			goto continue
		end
		normal.x = normal.x / normal_length
		normal.y = normal.y / normal_length
		normal.z = normal.z / normal_length
		
		-- Calculate plane constant d (plane equation: normal·P + d = 0)
		local d = -(normal.x * v1.x + normal.y * v1.y + normal.z * v1.z)
		
		-- Calculate denominator (normal · ray_dir)
		local denom = normal.x * ray_dir.x + normal.y * ray_dir.y + normal.z * ray_dir.z
		
		-- Check if ray is parallel to plane
		if math.abs(denom) < 0.000001 then
			goto continue
		end
		
		-- Calculate t (distance along ray to plane intersection)
		local t = -(normal.x * ray_origin.x + normal.y * ray_origin.y + normal.z * ray_origin.z + d) / denom
		
		-- Skip if intersection is behind ray origin
		if t < 0 then
			goto continue
		end
		
		-- Calculate intersection point
		local hit_point = {
			x = ray_origin.x + ray_dir.x * t,
			y = ray_origin.y + ray_dir.y * t,
			z = ray_origin.z + ray_dir.z * t
		}
		
		-- Check if hit point is inside the face quadrilateral
		-- Use the 4 vertices of the face
		local face_verts = {
			vertices[face.indices[1]],
			vertices[face.indices[2]],
			vertices[face.indices[3]],
			vertices[face.indices[4]]
		}
		
		-- Point-in-quadrilateral test using cross products
		-- For each edge, check if point is on the correct side
		local inside = true
		for i = 1, 4 do
			local v_curr = face_verts[i]
			local v_next = face_verts[(i % 4) + 1]
			
			-- Edge vector
			local edge = {x = v_next.x - v_curr.x, y = v_next.y - v_curr.y, z = v_next.z - v_curr.z}
			-- Vector from current vertex to hit point
			local to_point = {x = hit_point.x - v_curr.x, y = hit_point.y - v_curr.y, z = hit_point.z - v_curr.z}
			
			-- Cross product: edge × to_point
			local cross = {
				x = edge.y * to_point.z - edge.z * to_point.y,
				y = edge.z * to_point.x - edge.x * to_point.z,
				z = edge.x * to_point.y - edge.y * to_point.x
			}
			
			-- Dot product with face normal (should be positive if inside)
			local dot = cross.x * normal.x + cross.y * normal.y + cross.z * normal.z
			
			if dot < -0.000001 then
				inside = false
				break
			end
		end
		
		if inside and t < closest_distance then
			closest_distance = t
			closest_hit = hit_point
			closest_axis = face.name
			
			-- Calculate hit position in local (unrotated) coordinates
			local hit_pos_translated = {
				x = hit_point.x - box_position.x,
				y = hit_point.y - box_position.y,
				z = hit_point.z - box_position.z
			}
			
			-- Apply reverse rotation
			local cos_yaw = math.cos(-box_yaw)
			local sin_yaw = math.sin(-box_yaw)
			closest_hit_local = {
				x = hit_pos_translated.x * cos_yaw - hit_pos_translated.z * sin_yaw,
				y = hit_pos_translated.y,
				z = hit_pos_translated.x * sin_yaw + hit_pos_translated.z * cos_yaw
			}
			
			if verbose > 3 and debug_name then
				print(string.format("[hitboxes_lib]         Face %s: HIT at t=%.4f, local=(%.2f,%.2f,%.2f)", 
					face.name, t, closest_hit_local.x, closest_hit_local.y, closest_hit_local.z))
			end
		end
		
		::continue::
	end
	
	if closest_hit then
		if verbose > 3 and debug_name then
			print(string.format("[hitboxes_lib]         OBB SUCCESS: Hit at t=%.4f on axis %s", closest_distance, closest_axis))
		end
		return true, closest_distance, closest_axis, closest_hit_local, closest_hit
	end
	
	if verbose > 3 and debug_name then
		print(string.format("[hitboxes_lib]         OBB MISS: No valid face intersection"))
	end
	
	return false, nil, nil, nil, nil
end

-- Helper function for sphere-box intersection
local function sphere_intersects_aabb(center, radius, aabb)
	local dx = math.max(aabb.x_min - center.x, 0, center.x - aabb.x_max)
	local dy = math.max(aabb.y_min - center.y, 0, center.y - aabb.y_max)
	local dz = math.max(aabb.z_min - center.z, 0, center.z - aabb.z_max)
	
	return (dx*dx + dy*dy + dz*dz) <= (radius*radius)
end

-- Helper function to find closest point on ray to AABB
-- Returns: closest distance from ray to box, and parameter t where this occurs
local function ray_to_aabb_distance(ray_origin, ray_dir, aabb)
	-- Find closest point on AABB to ray
	-- For each axis, clamp the ray to the box bounds and find closest approach
	
	local min_dist_sq = math.huge
	local best_t = 0
	
	-- Sample multiple points along ray near the box
	-- Find the point on ray closest to box center
	local box_center = {
		x = (aabb.x_min + aabb.x_max) / 2,
		y = (aabb.y_min + aabb.y_max) / 2,
		z = (aabb.z_min + aabb.z_max) / 2
	}
	
	-- Project box center onto ray
	local to_center = {
		x = box_center.x - ray_origin.x,
		y = box_center.y - ray_origin.y,
		z = box_center.z - ray_origin.z
	}
	local t_center = to_center.x * ray_dir.x + to_center.y * ray_dir.y + to_center.z * ray_dir.z
	
	-- Test several t values around the projected point
	local test_t_values = {
		math.max(0, t_center - 1),
		math.max(0, t_center),
		math.max(0, t_center + 1)
	}
	
	for _, t in ipairs(test_t_values) do
		local point = {
			x = ray_origin.x + ray_dir.x * t,
			y = ray_origin.y + ray_dir.y * t,
			z = ray_origin.z + ray_dir.z * t
		}
		
		-- Distance from point to AABB
		local dx = math.max(aabb.x_min - point.x, 0, point.x - aabb.x_max)
		local dy = math.max(aabb.y_min - point.y, 0, point.y - aabb.y_max)
		local dz = math.max(aabb.z_min - point.z, 0, point.z - aabb.z_max)
		local dist_sq = dx*dx + dy*dy + dz*dz
		
		if dist_sq < min_dist_sq then
			min_dist_sq = dist_sq
			best_t = t
		end
	end
	
	return math.sqrt(min_dist_sq), best_t
end

-- Helper function for box-box intersection
local function aabb_intersects_aabb(a, b)
	return (a.x_min <= b.x_max and a.x_max >= b.x_min) and
	       (a.y_min <= b.y_max and a.y_max >= b.y_min) and
	       (a.z_min <= b.z_max and a.z_max >= b.z_min)
end

-- Helper function to convert global hit axis to local (rotated) hit axis
-- Takes a hit axis in global coords and applies reverse yaw rotation
local function global_axis_to_local(global_axis, yaw)
	-- Y axis is not affected by yaw rotation
	if global_axis == "y+" or global_axis == "y-" then
		return global_axis
	end
	
	-- Normalize yaw to 0-2π range
	local normalized_yaw = yaw % (2 * math.pi)
	if normalized_yaw < 0 then
		normalized_yaw = normalized_yaw + 2 * math.pi
	end
	
	-- Determine which quadrant (0-45°, 45-135°, 135-225°, 225-315°, 315-360°)
	local angle_deg = math.deg(normalized_yaw)
	
	-- Apply reverse rotation mapping
	-- When object is rotated by yaw, its local axes rotate in opposite direction
	if global_axis == "z-" then  -- Front in global coords
		if angle_deg < 45 or angle_deg >= 315 then
			return "z-"  -- Still front
		elseif angle_deg >= 45 and angle_deg < 135 then
			return "x-"  -- Rotated to left
		elseif angle_deg >= 135 and angle_deg < 225 then
			return "z+"  -- Rotated to back
		else  -- 225-315
			return "x+"  -- Rotated to right
		end
	elseif global_axis == "z+" then  -- Back in global coords
		if angle_deg < 45 or angle_deg >= 315 then
			return "z+"
		elseif angle_deg >= 45 and angle_deg < 135 then
			return "x+"
		elseif angle_deg >= 135 and angle_deg < 225 then
			return "z-"
		else
			return "x-"
		end
	elseif global_axis == "x-" then  -- Left in global coords
		if angle_deg < 45 or angle_deg >= 315 then
			return "x-"
		elseif angle_deg >= 45 and angle_deg < 135 then
			return "z+"
		elseif angle_deg >= 135 and angle_deg < 225 then
			return "x+"
		else
			return "z-"
		end
	elseif global_axis == "x+" then  -- Right in global coords
		if angle_deg < 45 or angle_deg >= 315 then
			return "x+"
		elseif angle_deg >= 45 and angle_deg < 135 then
			return "z-"
		elseif angle_deg >= 135 and angle_deg < 225 then
			return "x-"
		else
			return "z+"
		end
	end
	
	return global_axis
end

--------------------------------------------------------------------------------
-- Public API Functions
--------------------------------------------------------------------------------

-- Register hitboxes for a group
function hitboxes_lib.register_hitboxes(group_name, hitboxes)
	if verbose > 0 then
		print("[hitboxes_lib] Registering hitbox group: " .. group_name)
	end
	
	-- Normalize all boxes to 8 vertices
	local normalized_boxes = {}
	
	for name, box_def in pairs(hitboxes) do
		-- Copy all fields from box_def (including custom fields)
		local normalized_box = {}
		for key, value in pairs(box_def) do
			normalized_box[key] = value
		end
		
		-- Override 'box' field with normalized vertices
		normalized_box.vertices = normalize_box(box_def.box)
		normalized_box.box = nil  -- Remove original 'box' field
		
		-- Set default priority if not provided
		if not normalized_box.priority then
			normalized_box.priority = 0
		end
		
		normalized_boxes[name] = normalized_box
		
		if verbose > 1 then
			print("[hitboxes_lib]   - Box '" .. name .. "' (priority: " .. normalized_box.priority .. ")")
		end
	end
	
	hitbox_groups[group_name] = normalized_boxes
	
	if verbose > 0 then
		print("[hitboxes_lib] Registered " .. #normalized_boxes .. " hitboxes for group: " .. group_name)
	end
end

-- Convert collisionbox format to hitbox format
-- Luanti collisionbox format: {x1, y1, z1, x2, y2, z2} (array)
-- This mod's hitbox format: {x_min = number, y_min = number, ...} (table with named fields)
-- Note: Automatically determines min/max values regardless of input order
function hitboxes_lib.collisionbox_to_box(collisionbox)
	if not collisionbox or #collisionbox ~= 6 then
		error("Invalid collisionbox format - must be array of 6 numbers: {x1, y1, z1, x2, y2, z2}")
	end
	
	-- Determine min and max for each axis
	local x1, y1, z1 = collisionbox[1], collisionbox[2], collisionbox[3]
	local x2, y2, z2 = collisionbox[4], collisionbox[5], collisionbox[6]
	
	return {
		x_min = math.min(x1, x2),
		y_min = math.min(y1, y2),
		z_min = math.min(z1, z2),
		x_max = math.max(x1, x2),
		y_max = math.max(y1, y2),
		z_max = math.max(z1, z2)
	}
end

-- Transform attack box to relative coordinates
-- Used to transform attacker's local hitbox to coordinates relative to reference position
function hitboxes_lib.transform_attack_box(attacker_box, attacker_pos, attacker_rot, ref_pos)
	local yaw = attacker_rot.y or attacker_rot or 0
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Transforming attack box from attacker pos (%.2f, %.2f, %.2f), yaw: %.2f", 
			attacker_pos.x, attacker_pos.y, attacker_pos.z, yaw))
		print(string.format("[hitboxes_lib] Reference pos: (%.2f, %.2f, %.2f)", ref_pos.x, ref_pos.y, ref_pos.z))
	end
	
	-- Calculate relative position of attacker to reference
	local rel_pos = {
		x = attacker_pos.x - ref_pos.x,
		y = attacker_pos.y - ref_pos.y,
		z = attacker_pos.z - ref_pos.z
	}
	
	-- Check if attack_box is in AABB format or vertices format
	local vertices
	if attacker_box.x_min and attacker_box.x_max then
		-- Convert AABB to 8 vertices
		vertices = normalize_box(attacker_box)
	elseif #attacker_box == 8 then
		-- Already vertices
		vertices = attacker_box
	else
		error("Invalid attack_box format - must be AABB {x_min, y_min, z_min, x_max, y_max, z_max} or 8 vertices")
	end
	
	-- Transform each vertex: rotate by attacker's yaw, then translate by relative position
	local transformed_vertices = {}
	for i, vertex in ipairs(vertices) do
		-- Rotate vertex by attacker's yaw
		local rotated = rotate_point(vertex, yaw)
		-- Translate to relative position
		transformed_vertices[i] = vector_add(rotated, rel_pos)
	end
	
	-- Return as AABB in relative coordinates
	local result_aabb = get_aabb_from_vertices(transformed_vertices)
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Transformed attack box AABB: (%.2f, %.2f, %.2f) to (%.2f, %.2f, %.2f)",
			result_aabb.x_min, result_aabb.y_min, result_aabb.z_min,
			result_aabb.x_max, result_aabb.y_max, result_aabb.z_max))
	end
	
	return result_aabb
end

-- Get transformed boxes for a group at given position and rotation
function hitboxes_lib.get_transformed_boxes(group_name, pos, rot)
	local group = hitbox_groups[group_name]
	if not group then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Group '" .. group_name .. "' not found")
		end
		return {}
	end
	
	local yaw = rot.y or rot or 0
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Getting transformed boxes for group '%s' at pos (%.2f, %.2f, %.2f), yaw: %.2f", 
			group_name, pos.x, pos.y, pos.z, yaw))
	end
	
	local result = {}
	local box_count = 0
	
	for name, box_def in pairs(group) do
		local rotated_vertices = {}
		
		-- Rotate and translate each vertex
		for i, vertex in ipairs(box_def.vertices) do
			local rotated = rotate_point(vertex, yaw)
			rotated_vertices[i] = vector_add(rotated, pos)
		end
		
		result[name] = {
			vertices = rotated_vertices,
			aabb = get_aabb_from_vertices(rotated_vertices),
			original_vertices = box_def.vertices,  -- Store original unrotated vertices
			yaw = yaw,  -- Store yaw for reverse rotation
			position = pos,  -- Store position for reverse translation
			orig = box_def  -- Reference to original hitbox definition with all custom fields
		}
		
		box_count = box_count + 1
		
		if verbose > 2 then
			local aabb = result[name].aabb
			print(string.format("[hitboxes_lib]   Box '%s' AABB: (%.2f,%.2f,%.2f) to (%.2f,%.2f,%.2f)",
				name, aabb.x_min, aabb.y_min, aabb.z_min, aabb.x_max, aabb.y_max, aabb.z_max))
		end
	end
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Created %d transformed boxes", box_count))
	end
	
	return result
end

-- Raycast hit detection
function hitboxes_lib.raycast_hit(hitboxes, hit_data, max_distance)
	if verbose > 1 then
		print("[hitboxes_lib] Raycast hit detection")
	end
	
	local ref_pos = hit_data.ref_pos
	local hit_from_relpos = hit_data.hit_from_relpos
	local hit_from_dir = hit_data.hit_from_dir
	
	if not ref_pos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing ref_pos (reference position) in hit_data")
		end
		return nil
	end
	
	if not hit_from_relpos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_from_relpos (relative position) in hit_data")
		end
		return nil
	end
	
	if not hit_from_dir then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_from_dir in hit_data")
		end
		return nil
	end
	
	if not hitboxes or type(hitboxes) ~= "table" then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Invalid hitboxes parameter")
		end
		return nil
	end
	
	-- Ray origin in relative coordinates (directly from hit_from_relpos)
	local ray_origin = {
		x = hit_from_relpos.x,
		y = hit_from_relpos.y,
		z = hit_from_relpos.z
	}
	local ray_dir = hit_from_dir
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Reference pos: (%.2f, %.2f, %.2f)", ref_pos.x, ref_pos.y, ref_pos.z))
		print(string.format("[hitboxes_lib] Ray origin (relative): (%.2f, %.2f, %.2f)", ray_origin.x, ray_origin.y, ray_origin.z))
		print(string.format("[hitboxes_lib] Ray dir: (%.2f, %.2f, %.2f)", ray_dir.x, ray_dir.y, ray_dir.z))
		
		-- Show ray path at key distances
		print("[hitboxes_lib] Ray path samples (relative coords):")
		for t = 0, 4, 0.5 do
			local sample_pos = {
				x = ray_origin.x + ray_dir.x * t,
				y = ray_origin.y + ray_dir.y * t,
				z = ray_origin.z + ray_dir.z * t
			}
			print(string.format("[hitboxes_lib]   t=%.1f: (%.2f, %.2f, %.2f)", t, sample_pos.x, sample_pos.y, sample_pos.z))
		end
		
		if max_distance then
			print(string.format("[hitboxes_lib] Max distance: %.2f", max_distance))
		else
			print("[hitboxes_lib] Max distance: unlimited")
		end
	end
	
	-- Normalize ray direction
	local length = math.sqrt(ray_dir.x^2 + ray_dir.y^2 + ray_dir.z^2)
	if length == 0 then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Zero-length ray direction")
		end
		return {}
	end
	ray_dir = {x = ray_dir.x/length, y = ray_dir.y/length, z = ray_dir.z/length}
	
	-- Find intersections
	local hits = {}
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Testing %d hitboxes for ray intersection", 
			(function() local c=0; for _ in pairs(hitboxes) do c=c+1 end return c end)()))
	end
	
	for name, box in pairs(hitboxes) do
		if verbose > 2 then
			print(string.format("[hitboxes_lib]   Testing box '%s' AABB: (%.2f,%.2f,%.2f) to (%.2f,%.2f,%.2f)",
				name, box.aabb.x_min, box.aabb.y_min, box.aabb.z_min, 
				box.aabb.x_max, box.aabb.y_max, box.aabb.z_max))
		end
		
		-- First do AABB test for broadphase (quick rejection)
		local aabb_intersects = ray_intersects_aabb(ray_origin, ray_dir, box.aabb, nil)
		
		if not aabb_intersects then
			if verbose > 2 then
				print(string.format("[hitboxes_lib]     -> AABB MISS (broadphase rejection)"))
			end
			goto continue
		end
		
		-- AABB hit, now do precise OBB test
		local intersects, distance, hit_axis, hit_pos_local, hit_pos = ray_intersects_obb(
			ray_origin, ray_dir, 
			box.vertices, box.original_vertices, 
			box.position, box.yaw, 
			verbose > 3 and name or nil
		)
		
		if verbose > 2 then
			if intersects then
				print(string.format("[hitboxes_lib]     -> OBB HIT at distance %.2f on axis %s", distance, hit_axis))
			else
				print(string.format("[hitboxes_lib]     -> OBB MISS (failed precise test)"))
			end
		end
		
		if intersects and (not max_distance or distance <= max_distance) then
			if verbose > 2 then
				print(string.format("[hitboxes_lib] Ray hit box '%s' at distance %.2f", name, distance))
			end
			
			-- Get AABB of original unrotated box
			local orig_aabb = get_aabb_from_vertices(box.original_vertices)
			
			-- Normalize to 0..1 range relative to original unrotated box
			-- hit_pos_local is already in local (unrotated) coordinates from ray_intersects_obb
			local hit_relative = {
				x = (hit_pos_local.x - orig_aabb.x_min) / (orig_aabb.x_max - orig_aabb.x_min),
				y = (hit_pos_local.y - orig_aabb.y_min) / (orig_aabb.y_max - orig_aabb.y_min),
				z = (hit_pos_local.z - orig_aabb.z_min) / (orig_aabb.z_max - orig_aabb.z_min)
			}
			
			-- Clamp to 0..1 range (in case of floating point errors)
			hit_relative.x = math.max(0, math.min(1, hit_relative.x))
			hit_relative.y = math.max(0, math.min(1, hit_relative.y))
			hit_relative.z = math.max(0, math.min(1, hit_relative.z))
			
			table.insert(hits, {
				name = name,
				distance = distance,
				position = hit_pos,
				hit_relative = hit_relative,
				hit_axis = hit_axis,  -- Already in local coordinates from OBB test
				orig = box.orig
			})
		elseif intersects and max_distance then
			if verbose > 2 then
				print(string.format("[hitboxes_lib]     -> Beyond max_distance (%.2f > %.2f)", distance, max_distance))
			end
		end
		
		::continue::
	end
	
	-- Sort by distance (first hit first)
	table.sort(hits, function(a, b) return a.distance < b.distance end)
	
	if verbose > 1 then
		print(string.format("[hitboxes_lib] Raycast found %d hits", #hits))
	end
	
	-- Return nil if no hits
	if #hits == 0 then
		return nil
	end
	
	return hits
end

-- Sphere hit detection
function hitboxes_lib.sphere_hit(hitboxes, hit_data)
	local ref_pos = hit_data.ref_pos
	local hit_from_relpos = hit_data.hit_from_relpos
	local hit_from_dir = hit_data.hit_from_dir
	local radius = hit_data.radius or hit_data.sphere_radius or 1.0
	local max_distance = hit_data.range or 10
	
	if not ref_pos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing ref_pos (reference position) in hit_data")
		end
		return nil
	end
	
	if not hit_from_relpos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_from_relpos (relative position) in hit_data")
		end
		return nil
	end
	
	if not hit_from_dir then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_from_dir in hit_data")
		end
		return nil
	end
	
	-- Sphere center in relative coordinates (directly from hit_from_relpos)
	local center = {
		x = hit_from_relpos.x,
		y = hit_from_relpos.y,
		z = hit_from_relpos.z
	}
	
	if verbose > 1 then
		print(string.format("[hitboxes_lib] Sphere hit detection, center: (%.2f, %.2f, %.2f), radius: %.2f",
			center.x, center.y, center.z, radius))
	end
	
	if not hitboxes or type(hitboxes) ~= "table" then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Invalid hitboxes parameter")
		end
		return nil
	end
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Reference pos: (%.2f, %.2f, %.2f)", ref_pos.x, ref_pos.y, ref_pos.z))
		print(string.format("[hitboxes_lib] Max range: %.2f", max_distance))
	end
	
	-- Get hit direction
	local look_dir = hit_from_dir
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Ray origin (sphere center): (%.2f,%.2f,%.2f)", 
			center.x, center.y, center.z))
		print(string.format("[hitboxes_lib] Look dir: (%.2f,%.2f,%.2f)", look_dir.x, look_dir.y, look_dir.z))
		print(string.format("[hitboxes_lib] Sphere radius: %.2f", radius))
	end
	
	-- Find intersections
	local hits = {}
	
	for name, box in pairs(hitboxes) do
		if verbose > 3 then
			print(string.format("[hitboxes_lib]   Testing box '%s' AABB: (%.2f,%.2f,%.2f) to (%.2f,%.2f,%.2f)",
				name, box.aabb.x_min, box.aabb.y_min, box.aabb.z_min,
				box.aabb.x_max, box.aabb.y_max, box.aabb.z_max))
		end
		
		-- Step 1: Test if ray intersects the box directly (using OBB)
		local aabb_intersects = ray_intersects_aabb(center, look_dir, box.aabb)
		
		if aabb_intersects then
			-- Do precise OBB test
			local ray_intersects, ray_distance, hit_axis, hit_pos_local, sphere_pos_at_hit = ray_intersects_obb(
				center, look_dir,
				box.vertices, box.original_vertices,
				box.position, box.yaw,
				verbose > 3 and name or nil
			)
			
			if ray_intersects and ray_distance <= max_distance then
				-- Direct hit - ray passes through the box
				if verbose > 3 then
					print(string.format("[hitboxes_lib]     -> Ray DIRECT HIT at distance %.2f on axis %s", ray_distance, hit_axis))
				end
				
				local orig_aabb = get_aabb_from_vertices(box.original_vertices)
				local hit_relative = {
					x = (hit_pos_local.x - orig_aabb.x_min) / (orig_aabb.x_max - orig_aabb.x_min),
					y = (hit_pos_local.y - orig_aabb.y_min) / (orig_aabb.y_max - orig_aabb.y_min),
					z = (hit_pos_local.z - orig_aabb.z_min) / (orig_aabb.z_max - orig_aabb.z_min)
				}
				
				hit_relative.x = math.max(0, math.min(1, hit_relative.x))
				hit_relative.y = math.max(0, math.min(1, hit_relative.y))
				hit_relative.z = math.max(0, math.min(1, hit_relative.z))
				
				table.insert(hits, {
					name = name,
					distance = ray_distance,
					position = sphere_pos_at_hit,
					hit_relative = hit_relative,
					hit_axis = hit_axis,  -- Already in local coordinates
					orig = box.orig
				})
				goto continue_sphere
			end
		end
		
		-- If we got here, ray didn't hit directly
		-- Step 2: Ray misses box - check if sphere can still hit due to its radius
		do
			local min_distance, closest_t = ray_to_aabb_distance(center, look_dir, box.aabb)
			
			if verbose > 3 then
				print(string.format("[hitboxes_lib]     -> Ray miss, closest distance: %.2f at t=%.2f", min_distance, closest_t))
			end
			
			if min_distance <= radius and closest_t <= max_distance then
				-- Sphere might hit - do precise test
				if verbose > 3 then
					print(string.format("[hitboxes_lib]     -> Distance <= radius (%.2f <= %.2f), testing collision", min_distance, radius))
				end
				
				local sphere_pos_at_closest = {
					x = center.x + look_dir.x * closest_t,
					y = center.y + look_dir.y * closest_t,
					z = center.z + look_dir.z * closest_t
				}
				
				if sphere_intersects_aabb(sphere_pos_at_closest, radius, box.aabb) then
					if verbose > 3 then
						print(string.format("[hitboxes_lib]     -> COLLISION CONFIRMED at distance %.2f", closest_t))
					end
					
					-- Calculate hit_relative (same logic as raycast_hit)
					local hit_pos_translated = {
						x = sphere_pos_at_closest.x - box.position.x,
						y = sphere_pos_at_closest.y - box.position.y,
						z = sphere_pos_at_closest.z - box.position.z
					}
					
					local cos_yaw = math.cos(-box.yaw)
					local sin_yaw = math.sin(-box.yaw)
					local hit_pos_local = {
						x = hit_pos_translated.x * cos_yaw - hit_pos_translated.z * sin_yaw,
						y = hit_pos_translated.y,
						z = hit_pos_translated.x * sin_yaw + hit_pos_translated.z * cos_yaw
					}
					
					local orig_aabb = get_aabb_from_vertices(box.original_vertices)
					local hit_relative = {
						x = (hit_pos_local.x - orig_aabb.x_min) / (orig_aabb.x_max - orig_aabb.x_min),
						y = (hit_pos_local.y - orig_aabb.y_min) / (orig_aabb.y_max - orig_aabb.y_min),
						z = (hit_pos_local.z - orig_aabb.z_min) / (orig_aabb.z_max - orig_aabb.z_min)
					}
					
					hit_relative.x = math.max(0, math.min(1, hit_relative.x))
					hit_relative.y = math.max(0, math.min(1, hit_relative.y))
					hit_relative.z = math.max(0, math.min(1, hit_relative.z))
					
					-- Determine hit axis based on which face is closest
					local proximity_axis = nil
					if hit_relative.x <= 0.001 then
						proximity_axis = "x-"
					elseif hit_relative.x >= 0.999 then
						proximity_axis = "x+"
					elseif hit_relative.y <= 0.001 then
						proximity_axis = "y-"
					elseif hit_relative.y >= 0.999 then
						proximity_axis = "y+"
					elseif hit_relative.z <= 0.001 then
						proximity_axis = "z-"
					elseif hit_relative.z >= 0.999 then
						proximity_axis = "z+"
					else
						-- Hit is inside or on edge, determine by closest face
						local dx_min = hit_relative.x
						local dx_max = 1.0 - hit_relative.x
						local dy_min = hit_relative.y
						local dy_max = 1.0 - hit_relative.y
						local dz_min = hit_relative.z
						local dz_max = 1.0 - hit_relative.z
						
						local min_dist = math.min(dx_min, dx_max, dy_min, dy_max, dz_min, dz_max)
						if min_dist == dx_min then proximity_axis = "x-"
						elseif min_dist == dx_max then proximity_axis = "x+"
						elseif min_dist == dy_min then proximity_axis = "y-"
						elseif min_dist == dy_max then proximity_axis = "y+"
						elseif min_dist == dz_min then proximity_axis = "z-"
						else proximity_axis = "z+" end
					end
					
					-- Convert hit_axis from global to local coordinates
					local local_proximity_axis = global_axis_to_local(proximity_axis, box.yaw)
					
					table.insert(hits, {
						name = name,
						distance = closest_t,
						position = sphere_pos_at_closest,
						hit_relative = hit_relative,
						hit_axis = local_proximity_axis,
						orig = box.orig
					})
				else
					if verbose > 3 then
						print(string.format("[hitboxes_lib]     -> NO COLLISION (sphere doesn't overlap)"))
					end
				end
			else
				if verbose > 3 then
					if min_distance > radius then
						print(string.format("[hitboxes_lib]     -> Too far (%.2f > %.2f)", min_distance, radius))
					else
						print(string.format("[hitboxes_lib]     -> Beyond range (%.2f > %.2f)", closest_t, max_distance))
					end
				end
			end
		end
		
		::continue_sphere::
	end
	
	-- Sort by distance (closest first)
	table.sort(hits, function(a, b) return a.distance < b.distance end)
	
	if verbose > 1 then
		print(string.format("[hitboxes_lib] Sphere found %d hits", #hits))
	end
	
	-- Return nil if no hits
	if #hits == 0 then
		return nil
	end
	
	return hits
end

-- Box hit detection
function hitboxes_lib.box_hit(hitboxes, hit_data)
	if verbose > 1 then
		print("[hitboxes_lib] Box hit detection")
	end
	
	local ref_pos = hit_data.ref_pos
	local hit_from_relpos = hit_data.hit_from_relpos
	local hit_from_dir = hit_data.hit_from_dir
	local box = hit_data.box
	local box_rot = hit_data.box_rot or {x=0, y=0, z=0}
	local max_distance = hit_data.range or 10
	
	if not ref_pos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing ref_pos (reference position) in hit_data")
		end
		return nil
	end
	
	if not hit_from_relpos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_from_relpos (relative position) in hit_data")
		end
		return nil
	end
	
	if not hit_from_dir then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_from_dir in hit_data")
		end
		return nil
	end
	
	if not box then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing box in hit_data for box hit mode")
		end
		return nil
	end
	
	if not hitboxes or type(hitboxes) ~= "table" then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Invalid hitboxes parameter")
		end
		return nil
	end
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Reference pos: (%.2f, %.2f, %.2f)", ref_pos.x, ref_pos.y, ref_pos.z))
		print(string.format("[hitboxes_lib] Max range: %.2f", max_distance))
	end
	
	-- Transform box to relative coordinates
	-- hit_from_relpos is already relative, need to convert to absolute for transform_attack_box
	local abs_pos = {
		x = hit_from_relpos.x + ref_pos.x,
		y = hit_from_relpos.y + ref_pos.y,
		z = hit_from_relpos.z + ref_pos.z
	}
	local attack_box = hitboxes_lib.transform_attack_box(box, abs_pos, box_rot, ref_pos)
	
	-- Get attack box AABB
	local attack_aabb = attack_box
	
	-- Calculate center of attack box (ray origin)
	local ray_origin = {
		x = (attack_aabb.x_min + attack_aabb.x_max) / 2,
		y = (attack_aabb.y_min + attack_aabb.y_max) / 2,
		z = (attack_aabb.z_min + attack_aabb.z_max) / 2
	}
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Attack box AABB: (%.2f,%.2f,%.2f) to (%.2f,%.2f,%.2f)",
			attack_aabb.x_min, attack_aabb.y_min, attack_aabb.z_min,
			attack_aabb.x_max, attack_aabb.y_max, attack_aabb.z_max))
		print(string.format("[hitboxes_lib] Ray origin (box center): (%.2f,%.2f,%.2f)", 
			ray_origin.x, ray_origin.y, ray_origin.z))
		print(string.format("[hitboxes_lib] Look dir: (%.2f,%.2f,%.2f)", hit_from_dir.x, hit_from_dir.y, hit_from_dir.z))
	end
	
	-- Calculate attack box dimensions for collision test
	local box_half_size = {
		x = (attack_aabb.x_max - attack_aabb.x_min) / 2,
		y = (attack_aabb.y_max - attack_aabb.y_min) / 2,
		z = (attack_aabb.z_max - attack_aabb.z_min) / 2
	}
	
	-- Find intersections
	local hits = {}
	
	for name, box in pairs(hitboxes) do
		if verbose > 3 then
			print(string.format("[hitboxes_lib]   Testing box '%s' AABB: (%.2f,%.2f,%.2f) to (%.2f,%.2f,%.2f)",
				name, box.aabb.x_min, box.aabb.y_min, box.aabb.z_min,
				box.aabb.x_max, box.aabb.y_max, box.aabb.z_max))
		end
		
		-- Step 1: Test if ray intersects the box directly (using OBB)
		local aabb_intersects = ray_intersects_aabb(ray_origin, hit_from_dir, box.aabb)
		
		if aabb_intersects then
			-- Do precise OBB test
			local ray_intersects, ray_distance, hit_axis, hit_pos_local, box_pos_at_hit = ray_intersects_obb(
				ray_origin, hit_from_dir,
				box.vertices, box.original_vertices,
				box.position, box.yaw,
				verbose > 3 and name or nil
			)
			
			if ray_intersects and ray_distance <= max_distance then
				-- Direct hit - ray passes through the box
				if verbose > 3 then
					print(string.format("[hitboxes_lib]     -> Ray DIRECT HIT at distance %.2f on axis %s", ray_distance, hit_axis))
				end
				
				local orig_aabb = get_aabb_from_vertices(box.original_vertices)
				local hit_relative = {
					x = (hit_pos_local.x - orig_aabb.x_min) / (orig_aabb.x_max - orig_aabb.x_min),
					y = (hit_pos_local.y - orig_aabb.y_min) / (orig_aabb.y_max - orig_aabb.y_min),
					z = (hit_pos_local.z - orig_aabb.z_min) / (orig_aabb.z_max - orig_aabb.z_min)
				}
				
				hit_relative.x = math.max(0, math.min(1, hit_relative.x))
				hit_relative.y = math.max(0, math.min(1, hit_relative.y))
				hit_relative.z = math.max(0, math.min(1, hit_relative.z))
				
				table.insert(hits, {
					name = name,
					distance = ray_distance,
					position = box_pos_at_hit,
					hit_relative = hit_relative,
					hit_axis = hit_axis,  -- Already in local coordinates
					orig = box.orig,
					vertices = box.vertices
				})
				goto continue_box
			end
		end
		
		-- If ray didn't hit, check swept box collision
		do
			-- Step 2: Ray misses box - check if attack box can still hit due to its size
			local max_box_size = math.max(box_half_size.x, box_half_size.y, box_half_size.z)
			local min_distance, closest_t = ray_to_aabb_distance(ray_origin, hit_from_dir, box.aabb)

			if verbose > 3 then
				print(string.format("[hitboxes_lib]     -> Ray miss, closest distance: %.2f at t=%.2f", min_distance, closest_t))
			end
			
			if min_distance <= max_box_size and closest_t <= max_distance then
				-- Attack box might hit - do precise test
				if verbose > 3 then
					print(string.format("[hitboxes_lib]     -> Distance <= box size (%.2f <= %.2f), testing collision", min_distance, max_box_size))
				end
				
				local box_pos_at_closest = {
					x = ray_origin.x + hit_from_dir.x * closest_t,
					y = ray_origin.y + hit_from_dir.y * closest_t,
					z = ray_origin.z + hit_from_dir.z * closest_t
				}
				
				local attack_box_at_closest = {
					x_min = box_pos_at_closest.x - box_half_size.x,
					y_min = box_pos_at_closest.y - box_half_size.y,
					z_min = box_pos_at_closest.z - box_half_size.z,
					x_max = box_pos_at_closest.x + box_half_size.x,
					y_max = box_pos_at_closest.y + box_half_size.y,
					z_max = box_pos_at_closest.z + box_half_size.z
				}
				
				if aabb_intersects_aabb(attack_box_at_closest, box.aabb) then
					if verbose > 3 then
						print(string.format("[hitboxes_lib]     -> COLLISION CONFIRMED at distance %.2f", closest_t))
					end
					
					-- Calculate hit_relative (same logic as raycast_hit)
					local hit_pos_translated = {
						x = box_pos_at_closest.x - box.position.x,
						y = box_pos_at_closest.y - box.position.y,
						z = box_pos_at_closest.z - box.position.z
					}
					
					local cos_yaw = math.cos(-box.yaw)
					local sin_yaw = math.sin(-box.yaw)
					local hit_pos_local = {
						x = hit_pos_translated.x * cos_yaw - hit_pos_translated.z * sin_yaw,
						y = hit_pos_translated.y,
						z = hit_pos_translated.x * sin_yaw + hit_pos_translated.z * cos_yaw
					}
					
					local orig_aabb = get_aabb_from_vertices(box.original_vertices)
					local hit_relative = {
						x = (hit_pos_local.x - orig_aabb.x_min) / (orig_aabb.x_max - orig_aabb.x_min),
						y = (hit_pos_local.y - orig_aabb.y_min) / (orig_aabb.y_max - orig_aabb.y_min),
						z = (hit_pos_local.z - orig_aabb.z_min) / (orig_aabb.z_max - orig_aabb.z_min)
					}
					
					hit_relative.x = math.max(0, math.min(1, hit_relative.x))
					hit_relative.y = math.max(0, math.min(1, hit_relative.y))
					hit_relative.z = math.max(0, math.min(1, hit_relative.z))
					
					-- Determine hit axis based on which face is closest
					local proximity_axis = nil
					if hit_relative.x <= 0.001 then
						proximity_axis = "x-"
					elseif hit_relative.x >= 0.999 then
						proximity_axis = "x+"
					elseif hit_relative.y <= 0.001 then
						proximity_axis = "y-"
					elseif hit_relative.y >= 0.999 then
						proximity_axis = "y+"
					elseif hit_relative.z <= 0.001 then
						proximity_axis = "z-"
					elseif hit_relative.z >= 0.999 then
						proximity_axis = "z+"
					else
						-- Hit is inside or on edge, determine by closest face
						local dx_min = hit_relative.x
						local dx_max = 1.0 - hit_relative.x
						local dy_min = hit_relative.y
						local dy_max = 1.0 - hit_relative.y
						local dz_min = hit_relative.z
						local dz_max = 1.0 - hit_relative.z
						
						local min_dist = math.min(dx_min, dx_max, dy_min, dy_max, dz_min, dz_max)
						if min_dist == dx_min then proximity_axis = "x-"
						elseif min_dist == dx_max then proximity_axis = "x+"
						elseif min_dist == dy_min then proximity_axis = "y-"
						elseif min_dist == dy_max then proximity_axis = "y+"
						elseif min_dist == dz_min then proximity_axis = "z-"
						else proximity_axis = "z+" end
					end
					
					-- Convert hit_axis from global to local coordinates
					local local_proximity_axis = global_axis_to_local(proximity_axis, box.yaw)
					
					table.insert(hits, {
						name = name,
						distance = closest_t,
						position = box_pos_at_closest,
						hit_relative = hit_relative,
						hit_axis = local_proximity_axis,
						orig = box.orig,
						vertices = box.vertices
					})
				else
					if verbose > 3 then
						print(string.format("[hitboxes_lib]     -> NO COLLISION (boxes don't overlap)"))
					end
				end
			else
				if verbose > 3 then
					if min_distance > max_box_size then
						print(string.format("[hitboxes_lib]     -> Too far (%.2f > %.2f)", min_distance, max_box_size))
					else
						print(string.format("[hitboxes_lib]     -> Beyond range (%.2f > %.2f)", closest_t, max_distance))
					end
				end
			end
		end
		
		::continue_box::
	end
	
	-- Sort by distance (closest first)
	table.sort(hits, function(a, b) return a.distance < b.distance end)
	
	if verbose > 1 then
		print(string.format("[hitboxes_lib] Box found %d hits", #hits))
	end
	
	-- Return nil if no hits
	if #hits == 0 then
		return nil
	end
	
	return hits
end

-- Get hitgroup name for a player
-- Is expected to be overwritten by mods to provide custom hitgroup names
function hitboxes_lib.get_player_hitgroup_name(player)
	return nil
end

-- Get hitgroup name for an object
-- Returns the hitgroup_name if hitboxes are registered, nil otherwise
function hitboxes_lib.get_hitgroup_name(object)
	if not object then
		return nil
	end
	
	-- Try to get from lua entity
	local luaent = object:get_luaentity()
	if luaent then
		local hitgroup_name = luaent.hitgroup_name or luaent.name
		if luaent.get_hitgroup_name then
			hitgroup_name = luaent:get_hitgroup_name()
		end
		if hitgroup_name and hitbox_groups[hitgroup_name] then
			return hitgroup_name
		end
	end
	
	-- Try player default
	if object:is_player() then
		return hitboxes_lib.get_player_hitgroup_name(object)
	end
	
	return nil
end

-- Detect hits using pre-computed hit_data
-- Returns list of hits or nil if no hits detected
-- This is a pure detection function - it does not process hits or call callbacks
-- 
-- hit_data structure must contain:
--   ref_pos: reference position (vector) - all coordinates are relative to this
--   hit_from_relpos: position from where hit test originates (vector, RELATIVE to ref_pos)
--   hit_from_dir: direction of hit test (normalized vector)
--   hitgroup_name: name of registered hitbox group to test against
--   hitbox_relpos: relative position of hitboxes (vector, RELATIVE to ref_pos)
--   hitbox_rot: rotation of hitboxes (rotation table or yaw number)
--   
--   Optional detection mode parameters:
--   mode: "raycast", "sphere", or "box" (default: "raycast")
--   range: maximum detection range (default: 4.0)
--   
--   For sphere mode:
--   sphere_radius: radius of sphere (number, default: 1.0)
--                  sphere center is automatically derived from hit_from_relpos
--   
--   For box mode:
--   box: box definition (AABB or 8 vertices) in local coordinates
--   box_rot: rotation of box (rotation table or yaw number, default: {0,0,0})
--            box is automatically transformed and positioned at hit_from_relpos
function hitboxes_lib.detect_hits(hit_data)
	if verbose > 1 then
		print("[hitboxes_lib] detect_hits called")
	end
	
	-- Validate required parameters
	if not hit_data then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_data in detect_hits")
		end
		return nil
	end
	
	local ref_pos = hit_data.ref_pos
	local hit_from_relpos = hit_data.hit_from_relpos
	local hit_from_dir = hit_data.hit_from_dir
	local hitgroup_name = hit_data.hitgroup_name
	local hitbox_relpos = hit_data.hitbox_relpos
	local hitbox_rot = hit_data.hitbox_rot
	
	if not ref_pos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing ref_pos in hit_data")
		end
		return nil
	end
	
	if not hit_from_relpos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_from_relpos (relative position) in hit_data")
		end
		return nil
	end
	
	if not hit_from_dir then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hit_from_dir in hit_data")
		end
		return nil
	end
	
	if not hitgroup_name then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hitgroup_name in hit_data")
		end
		return nil
	end
	
	if not hitbox_relpos then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hitbox_relpos (relative position) in hit_data")
		end
		return nil
	end
	
	if not hitbox_rot then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Missing hitbox_rot in hit_data")
		end
		return nil
	end
	
	-- Check if hitgroup exists
	if not hitbox_groups[hitgroup_name] then
		if verbose > 1 then
			print(string.format("[hitboxes_lib] No hitboxes registered for group '%s', skipping hitbox detection", hitgroup_name))
		end
		return nil
	end
	
	if verbose > 1 then
		print(string.format("[hitboxes_lib] Using hitbox group: '%s'", hitgroup_name))
	end
	
	-- Use hitbox relative position directly
	local move = {
		x = hitbox_relpos.x,
		y = hitbox_relpos.y,
		z = hitbox_relpos.z
	}
	
	if verbose > 2 then
		print(string.format("[hitboxes_lib] Reference pos: (%.2f, %.2f, %.2f)", ref_pos.x, ref_pos.y, ref_pos.z))
		print(string.format("[hitboxes_lib] Hitbox relpos: (%.2f, %.2f, %.2f)", move.x, move.y, move.z))
		print(string.format("[hitboxes_lib] Hit from relpos: (%.2f, %.2f, %.2f)", hit_from_relpos.x, hit_from_relpos.y, hit_from_relpos.z))
	end
	
	-- Get transformed hitboxes
	local hitboxes = hitboxes_lib.get_transformed_boxes(hitgroup_name, move, hitbox_rot)
	
	-- Get detection parameters
	local mode = hit_data.mode or "raycast"
	local range = hit_data.range or 4.0
	
	-- Perform hit detection based on mode
	local hits = nil
	
	if mode == "box" then
		if verbose > 1 then
			print("[hitboxes_lib] Using box hit detection mode")
		end
		hits = hitboxes_lib.box_hit(hitboxes, hit_data)
		
	elseif mode == "sphere" then
		if verbose > 1 then
			print("[hitboxes_lib] Using sphere hit detection mode")
		end
		hits = hitboxes_lib.sphere_hit(hitboxes, hit_data)
		
	else
		if verbose > 1 then
			print("[hitboxes_lib] Using raycast hit detection mode")
		end
		hits = hitboxes_lib.raycast_hit(hitboxes, hit_data, hit_data.range or range)
	end
	
	if verbose > 1 then
		if hits and #hits > 0 then
			print(string.format("[hitboxes_lib] Detected %d hitbox hit(s)", #hits))
		else
			print("[hitboxes_lib] No hitbox hits detected")
		end
	end
	
	return hits
end

-- Convenience wrapper for detect_hits using objects (backward compatibility)
-- Extracts necessary data from objects and calls the main detect_hits function
function hitboxes_lib.detect_hits_from_objects(defender_obj, attacker_obj, direction)
	if verbose > 1 then
		print("[hitboxes_lib] detect_hits_from_objects called (compatibility wrapper)")
	end
	
	-- Validate parameters
	if not defender_obj or not attacker_obj then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Invalid defender or attacker")
		end
		return nil
	end
	
	-- Get hitbox group name for defender
	local hitgroup_name = hitboxes_lib.get_hitgroup_name(defender_obj)
	
	if not hitgroup_name then
		if verbose > 1 then
			print("[hitboxes_lib] No hitboxes registered for defender")
		end
		return nil
	end
	
	-- Extract positions and rotations
	local defender_pos = defender_obj:get_pos()
	local attacker_pos = attacker_obj:get_pos()
	local attacker_rot = attacker_obj:get_rotation()
	
	-- Calculate attack direction
	local attack_dir = direction
	if not attack_dir then
		attack_dir = vector.subtract(defender_pos, attacker_pos)
		attack_dir = vector.normalize(attack_dir)
	end
	
	-- Get attack position (adjust for player eye height)
	local attack_pos = attacker_pos
	if attacker_obj:is_player() then
		local eye_height = attacker_obj:get_properties().eye_height
		attack_pos = vector.add(attacker_pos, vector.new(0, eye_height, 0))
		attack_dir = attacker_obj:get_look_dir()
	end
	
	-- Get defender rotation
	local defender_rot
	if defender_obj:is_player() then
		local look = defender_obj:get_look_dir()
		defender_rot = {x=0, y=look.y, z=0}
	else
		defender_rot = defender_obj:get_rotation()
	end
	
	-- Determine detection mode and parameters
	local mode = "raycast"
	local range = 4.0
	local extra_params = {}
	
	local def = nil
	if attacker_obj:is_player() then
		def = attacker_obj:get_wielded_item():get_definition()
		range = def.range or 4.0
	else
		local luaent = attacker_obj:get_luaentity()
		if luaent then
			def = luaent
			range = def._hit_range or 4.0
		end
	end
	
	if def then
		if def._box_hit then
			mode = "box"
			extra_params.box = def._box_hit
			extra_params.box_rot = attacker_rot
		elseif def._sphere_hit then
			mode = "sphere"
			extra_params.sphere_radius = def._sphere_hit_radius or 1.0
		end
	end
	
	-- Build hit_data structure
	local hit_data = {
		ref_pos = attacker_pos,
		hit_from_pos = attack_pos,
		hit_from_dir = attack_dir,
		hitgroup_name = hitgroup_name,
		hitbox_pos = defender_pos,
		hitbox_rot = defender_rot,
		mode = mode,
		range = range,
	}
	
	-- Add mode-specific parameters
	for k, v in pairs(extra_params) do
		hit_data[k] = v
	end
	
	-- Call main detection function
	return hitboxes_lib.detect_hits(hit_data)
end

-- Visualize hitboxes on an object (for debugging)
function hitboxes_lib.visualize_object_hitboxes(group_name, object, duration, attach)
	duration = duration or 5
	attach = attach == nil and true or attach  -- Default to true
	
	if verbose > 0 then
		print(string.format("[hitboxes_lib] Visualizing hitboxes for group '%s' (duration: %.1fs, attach: %s)", 
			group_name, duration, tostring(attach)))
	end
	
	local group = hitbox_groups[group_name]
	if not group then
		if verbose > 0 then
			print("[hitboxes_lib] Warning: Group '" .. group_name .. "' not found")
		end
		return
	end
	
	if attach then
		-- Attached mode - hitboxes follow the object
		local count = 0
		for name, box_def in pairs(group) do
			-- Get local AABB from vertices (without rotation/translation)
			local local_aabb = get_aabb_from_vertices(box_def.vertices)
			
			-- Create visualization entity at object position (will be attached)
			local entity = core.add_entity(object:get_pos(), "hitboxes_lib:hitbox_visualizer")
			if entity then
				local visual_size = object:get_properties().visual_size
				local size = {
					x = (local_aabb.x_max - local_aabb.x_min) / visual_size.x,
					y = (local_aabb.y_max - local_aabb.y_min) / visual_size.y,
					z = (local_aabb.z_max - local_aabb.z_min) / visual_size.z
				}

				-- Calculate local center position (must be multiplied by 10 for attachment)
				local local_pos = {
					x = (local_aabb.x_min + local_aabb.x_max) / 2 * 10 / visual_size.x,
					y = (local_aabb.y_min + local_aabb.y_max) / 2 * 10 / visual_size.y,
					z = (local_aabb.z_min + local_aabb.z_max) / 2 * 10 / visual_size.z
				}
				
				entity:set_properties({
					visual_size = size,
				})
				
				-- Attach to object with local offset (multiplied by 10)
				entity:set_attach(object, "", local_pos, {x=0, y=0, z=0})
				
				local luaent = entity:get_luaentity()
				if luaent then
					luaent.lifetime = duration
					luaent.box_name = name
					luaent.attached_to = object
				end
				
				count = count + 1
				
				if verbose > 1 then
					print(string.format("[hitboxes_lib]   Visualized box '%s' attached with local offset (%.2f, %.2f, %.2f)", 
						name, local_pos.x, local_pos.y, local_pos.z))
				end
			end
		end
		
		if verbose > 0 then
			print(string.format("[hitboxes_lib] Created %d visualization entities attached to object", count))
		end
	else
		-- Static mode - hitboxes at current transformed position (snapshot)
		local pos = object:get_pos()
		local rot = object:get_rotation()
		local boxes = hitboxes_lib.get_transformed_boxes(group_name, pos, rot)
		
		local count = 0
		for name, box in pairs(boxes) do
			-- Create visualization entity for each box at its current world position
			local box_pos = {
				x = (box.aabb.x_min + box.aabb.x_max) / 2,
				y = (box.aabb.y_min + box.aabb.y_max) / 2,
				z = (box.aabb.z_min + box.aabb.z_max) / 2
			}
			
			local entity = core.add_entity(box_pos, "hitboxes_lib:hitbox_visualizer")
			if entity then
				local size = {
					x = box.aabb.x_max - box.aabb.x_min,
					y = box.aabb.y_max - box.aabb.y_min,
					z = box.aabb.z_max - box.aabb.z_min
				}
				entity:set_properties({
					visual_size = size,
				})
				
				local luaent = entity:get_luaentity()
				if luaent then
					luaent.lifetime = duration
					luaent.box_name = name
				end
				
				count = count + 1
				
				if verbose > 1 then
					print(string.format("[hitboxes_lib]   Visualized box '%s' at static position (%.2f, %.2f, %.2f)", 
						name, box_pos.x, box_pos.y, box_pos.z))
				end
			end
		end
		
		if verbose > 0 then
			print(string.format("[hitboxes_lib] Created %d static visualization entities", count))
		end
	end
end

