-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- Time-based movement for ANSi art preview
-- @module plugins.ansipreview.motion

local common = require("core.common")

local motion = {}

-- Curves stay separate from profiles so each movement phase can choose one
local EASING_FUNCTIONS = {
	linear = function(t)
		return t
	end,
	quad_in = function(t)
		return t * t
	end,
	quad_out = function(t)
		return 1 - (1 - t) * (1 - t)
	end,
	quad_in_out = function(t)
		return t < 0.5 and 2 * t * t or 1 - ((-2 * t + 2) ^ 2) / 2
	end,
	cubic_in = function(t)
		return t * t * t
	end,
	cubic_out = function(t)
		return 1 - (1 - t) ^ 3
	end,
	cubic_in_out = function(t)
		return t < 0.5 and 4 * t * t * t or 1 - ((-2 * t + 2) ^ 3) / 2
	end,
	quart_in = function(t)
		return t ^ 4
	end,
	quart_out = function(t)
		return 1 - (1 - t) ^ 4
	end,
	quart_in_out = function(t)
		return t < 0.5 and 8 * t ^ 4 or 1 - ((-2 * t + 2) ^ 4) / 2
	end,
	sine_in = function(t)
		return 1 - math.cos((t * math.pi) / 2)
	end,
	sine_out = function(t)
		return math.sin((t * math.pi) / 2)
	end,
	sine_in_out = function(t)
		return -(math.cos(math.pi * t) - 1) / 2
	end,
	smoothstep = function(t)
		return t * t * (3 - 2 * t)
	end,
	smootherstep = function(t)
		return t * t * t * (t * (t * 6 - 15) + 10)
	end,
}

motion.easing_names = {
	"linear",
	"quad_in",
	"quad_out",
	"quad_in_out",
	"cubic_in",
	"cubic_out",
	"cubic_in_out",
	"quart_in",
	"quart_out",
	"quart_in_out",
	"sine_in",
	"sine_out",
	"sine_in_out",
	"smoothstep",
	"smootherstep",
}

--- Apply one named easing curve to a value between zero and one
-- @param name string curve name
-- @param value number progress value
-- @return number eased value
function motion.ease(name, value)
	local easing = EASING_FUNCTIONS[name] or EASING_FUNCTIONS.linear

	return easing(common.clamp(tonumber(value) or 0, 0, 1))
end

--- Build empty movement state for one preview
-- @return table state
function motion.new_state()
	return {
		line_direction = 0,
		line_travel_direction = 0,
		line_speed = 0,
		line_elapsed = 0,
		line_release_speed = 0,
		target = nil,
	}
end

--- Return whether line or target movement still needs frames
-- @param state table movement state
-- @return boolean active
function motion.is_active(state)
	return state.target ~= nil or state.line_direction ~= 0 or state.line_speed > 0
end

--- Stop every keyboard movement without changing the current position
-- @param state table movement state
function motion.cancel(state)
	state.line_direction = 0
	state.line_travel_direction = 0
	state.line_speed = 0
	state.line_elapsed = 0
	state.line_release_speed = 0
	state.target = nil
end

--- Start, reverse, or release continuous line movement
-- @param state table movement state
-- @param direction number negative, zero, or positive direction
-- @param profile table line movement settings
function motion.set_line_direction(state, direction, profile)
	direction = direction < 0 and -1 or (direction > 0 and 1 or 0)
	profile = profile or {}

	if direction == state.line_direction then
		return
	end

	state.target = nil
	state.line_elapsed = 0

	if direction == 0 then
		state.line_release_speed = state.line_speed
		state.line_direction = 0
		return
	end

	if state.line_travel_direction ~= direction then
		state.line_speed = math.max(0, tonumber(profile.start_speed) or 0)
	end

	state.line_direction = direction
	state.line_travel_direction = direction
	state.line_release_speed = 0
end

--- Advance held or released line movement by elapsed seconds
-- @param state table movement state
-- @param position number current scroll position
-- @param maximum number largest scroll position
-- @param elapsed number elapsed seconds
-- @param profile table line movement settings
-- @return number position
-- @return boolean active
function motion.step_line(state, position, maximum, elapsed, profile)
	profile = profile or {}
	elapsed = common.clamp(tonumber(elapsed) or 0, 0, 0.05)
	position = common.clamp(tonumber(position) or 0, 0, maximum)
	state.line_elapsed = state.line_elapsed + elapsed

	if state.line_direction ~= 0 then
		local duration = math.max(0, tonumber(profile.acceleration_time) or 0)
		local progress = duration == 0 and 1 or common.clamp(state.line_elapsed / duration, 0, 1)
		local start_speed = math.max(0, tonumber(profile.start_speed) or 0)
		local max_speed = math.max(start_speed, tonumber(profile.max_speed) or start_speed)
		local eased = motion.ease(profile.acceleration_easing, progress)
		state.line_speed = start_speed + (max_speed - start_speed) * eased
		state.line_travel_direction = state.line_direction
	else
		local duration = math.max(0, tonumber(profile.release_time) or 0)
		local progress = duration == 0 and 1 or common.clamp(state.line_elapsed / duration, 0, 1)
		local eased = motion.ease(profile.release_easing, progress)
		state.line_speed = math.max(0, state.line_release_speed * (1 - eased))

		if progress >= 1 then
			state.line_speed = 0
			state.line_travel_direction = 0
		end
	end

	local next_position = common.clamp(
		position + state.line_travel_direction * state.line_speed * elapsed,
		0,
		maximum
	)

	if next_position == 0 or next_position == maximum then
		state.line_speed = 0
	end

	return next_position, motion.is_active(state)
end

--- Start movement toward one exact destination
-- @param state table movement state
-- @param kind string target profile name
-- @param position number current scroll position
-- @param target number requested target position
-- @param maximum number largest scroll position
function motion.start_target(state, kind, position, target, maximum)
	motion.cancel(state)

	position = common.clamp(tonumber(position) or 0, 0, maximum)
	target = common.clamp(tonumber(target) or position, 0, maximum)

	if position == target then
		return
	end

	state.target = {
		kind = kind,
		position = target,
		initial_distance = math.abs(target - position),
		elapsed = 0,
		brake_elapsed = 0,
		brake_start_position = nil,
	}
end

--- Advance the timed final section of one target movement
-- @param state table movement state
-- @param target table active target state
-- @param position number current scroll position
-- @param elapsed number elapsed landing time
-- @param brake_time number full landing duration
-- @param brake_easing string easing curve name
-- @return number position
-- @return boolean active
local function step_timed_landing(state, target, position, elapsed, brake_time, brake_easing)
	if target.brake_start_position == nil then
		target.brake_start_position = position
		target.brake_elapsed = 0
	end

	target.brake_elapsed = target.brake_elapsed + elapsed
	local brake_progress = common.clamp(target.brake_elapsed / brake_time, 0, 1)
	local brake_start = target.brake_start_position
	local next_position = brake_start
		+ (target.position - brake_start) * motion.ease(brake_easing, brake_progress)

	if brake_progress >= 1 then
		state.target = nil
		return target.position, false
	end

	return next_position, true
end

--- Advance exact target movement without crossing its destination
-- @param state table movement state
-- @param position number current scroll position
-- @param maximum number largest scroll position
-- @param elapsed number elapsed seconds
-- @param profile table target movement settings
-- @return number position
-- @return boolean active
function motion.step_target(state, position, maximum, elapsed, profile)
	local target = state.target

	if not target then
		return position, false
	end

	profile = profile or {}
	elapsed = common.clamp(tonumber(elapsed) or 0, 0, 0.05)
	position = common.clamp(tonumber(position) or 0, 0, maximum)
	target.position = common.clamp(target.position, 0, maximum)
	target.elapsed = target.elapsed + elapsed

	local delta = target.position - position
	local remaining = math.abs(delta)
	local direction = delta < 0 and -1 or 1

	if remaining == 0 then
		state.target = nil
		return target.position, false
	end

	local start_speed = math.max(1, tonumber(profile.start_speed) or 1)
	local distance_speed_multiplier = math.max(0, tonumber(profile.distance_speed_multiplier) or 0)
	local distance_speed = target.initial_distance * distance_speed_multiplier
	local max_speed = math.max(start_speed, tonumber(profile.max_speed) or start_speed, distance_speed)
	local acceleration_time = math.max(0, tonumber(profile.acceleration_time) or 0)
	local acceleration_progress = acceleration_time == 0
		and 1
		or common.clamp(target.elapsed / acceleration_time, 0, 1)
	local acceleration_speed = start_speed + (max_speed - start_speed)
		* motion.ease(profile.acceleration_easing, acceleration_progress)

	local brake_ratio = common.clamp(tonumber(profile.brake_ratio) or 0, 0, 1)
	local minimum_brake_distance = math.max(0, tonumber(profile.minimum_brake_distance) or 0)
	local brake_distance = math.min(
		target.initial_distance,
		math.max(minimum_brake_distance, target.initial_distance * brake_ratio)
	)
	local allowed_speed = acceleration_speed
	local brake_time = math.max(0, tonumber(profile.brake_time) or 0)

	if brake_time > 0
		and (target.brake_start_position ~= nil or remaining <= brake_distance) then
		-- Timed braking makes long jumps visibly ease through their final section
		return step_timed_landing(
			state,
			target,
			position,
			elapsed,
			brake_time,
			profile.brake_easing
		)
	elseif brake_distance > 0 and remaining < brake_distance then
		local brake_progress = 1 - remaining / brake_distance
		local brake_factor = 1 - motion.ease(profile.brake_easing, brake_progress)
		local brake_speed = start_speed + (max_speed - start_speed) * brake_factor
		allowed_speed = math.min(allowed_speed, brake_speed)
	end

	local step = math.max(0, allowed_speed * elapsed)

	if brake_time > 0 and remaining > brake_distance then
		local flight_remaining = remaining - brake_distance

		if step >= flight_remaining then
			-- Mark the phase directly because decimal boundaries may not compare exactly next frame
			local brake_start = position + direction * flight_remaining
			local flight_elapsed = flight_remaining / allowed_speed
			local landing_elapsed = math.max(0, elapsed - flight_elapsed)
			target.brake_start_position = brake_start
			target.brake_elapsed = 0

			-- Use the rest of this frame so the phase change does not pause on its boundary
			return step_timed_landing(
				state,
				target,
				brake_start,
				landing_elapsed,
				brake_time,
				profile.brake_easing
			)
		end
	end

	if step >= remaining then
		state.target = nil
		return target.position, false
	end

	return position + direction * step, true
end

return motion
