--!strict
-- Oklab C implementation provided by Björn Ottosson:
--     https://bottosson.github.io/posts/gamutclipping/
-- Luau port and Roblox/Lch extensions by Elttob:
--     https://elttob.uk/
-- Licensed under MIT

local TAU = 2 * math.pi

local function cbrt(x: number)
	return math.sign(x) * math.abs(x) ^ (1/3)
end

local Oklab = {}

function Oklab.linear_srgb_to_oklab(
	c: Vector3
): Vector3
	local l = 0.4122214708 * c.X + 0.5363325363 * c.Y + 0.0514459929 * c.Z
	local m = 0.2119034982 * c.X + 0.6806995451 * c.Y + 0.1073969566 * c.Z
	local s = 0.0883024619 * c.X + 0.2817188376 * c.Y + 0.6299787005 * c.Z
	
	local l_ = cbrt(l)
	local m_ = cbrt(m)
	local s_ = cbrt(s)

	return Vector3.new(
		0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
		1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
		0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
	)
end

function Oklab.oklab_to_linear_srgb(
	c: Vector3
): Vector3
	local l_ = c.X + 0.3963377774 * c.Y + 0.2158037573 * c.Z
	local m_ = c.X - 0.1055613458 * c.Y - 0.0638541728 * c.Z
	local s_ = c.X - 0.0894841775 * c.Y - 1.2914855480 * c.Z

	local l = l_ * l_ * l_
	local m = m_ * m_ * m_
	local s = s_ * s_ * s_

	return Vector3.new(
		4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
		-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
		-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
	)
end

-- Finds the maximum saturation possible for a given hue that fits in sRGB.
-- Saturation here is defined as S = C/L
-- a and b must be normalised so a^2 + b^2 == 1
function Oklab.compute_max_saturation(
	a: number,
	b: number
): number
	-- Max saturation will be when one of r, g or b goes below zero.

	-- Select different coefficients depending on which component goes below zero first
	local k0, k1, k2, k3, k4, wl, wm, ws

	if -1.88170328 * a - 0.80936493 * b > 1 then
		-- Red component
		k0, k1, k2, k3, k4 = 1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245
		wl, wm, ws = 4.0767416621, -3.3077115913, 0.2309699292
	elseif 1.81444104 * a - 1.19445276 * b > 1 then
		-- Green component
		k0, k1, k2, k3, k4 = 0.73956515, -0.45954404, 0.08285427, 0.12541070, 0.14503204
		wl, wm, ws = -1.2684380046, 2.6097574011, -0.3413193965
	else
		-- Blue component
		k0, k1, k2, k3, k4 = 1.35733652, -0.00915799, -1.15130210, -0.50559606, 0.00692167
		wl, wm, ws = -0.0041960863, -0.7034186147, 1.7076147010
	end

	-- Approximate max saturation using a polynomial
	local S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b

	-- Do one step Halley's method to get closer
	-- this gives an error less than 10e6, except for some blue hues where the dS/dh is close to infinite
	-- this should be sufficient for most applications, otherwise do two/three steps
	local k_l =  0.3963377774 * a + 0.2158037573 * b
	local k_m = -0.1055613458 * a - 0.0638541728 * b
	local k_s = -0.0894841775 * a - 1.2914855480 * b

	do
		local l_ = 1 + S * k_l
		local m_ = 1 + S * k_m
		local s_ = 1 + S * k_s

		local l = l_ * l_ * l_
		local m = m_ * m_ * m_
		local s = s_ * s_ * s_

		local l_dS = 3 * k_l * l_ * l_
		local m_dS = 3 * k_m * m_ * m_
		local s_dS = 3 * k_s * s_ * s_

		local l_dS2 = 6 * k_l * k_l * l_
		local m_dS2 = 6 * k_m * k_m * m_
		local s_dS2 = 6 * k_s * k_s * s_

		local f  = wl * l     + wm * m     + ws * s
		local f1 = wl * l_dS  + wm * m_dS  + ws * s_dS
		local f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2

		S = S - f * f1 / (f1*f1 - 0.5 * f * f2)
	end

	return S
end

-- finds L_cusp and C_cusp for a given hue
-- a and b must be normalised so a^2 + b^2 == 1
function Oklab.find_cusp(
	a: number,
	b: number
): (number, number)
	-- First, find the maximum saturation (saturation S = C/L)
	local S_cusp = Oklab.compute_max_saturation(a, b)

	-- Convert to linear sRGB to find the first point where at least one of r,g or b >= 1:
	local rgb_at_max = Oklab.oklab_to_linear_srgb(Vector3.new(1, S_cusp * a, S_cusp * b))
	local L_cusp = cbrt(1 / math.max(rgb_at_max.X, rgb_at_max.Y, rgb_at_max.Z))
	local C_cusp = L_cusp * S_cusp

	return L_cusp, C_cusp
end

-- Finds intersection of the line defined by 
-- L = L0 * (1 - t) + t * L1;
-- C = t * C1;
-- a and b must be normalized so a^2 + b^2 == 1
function Oklab.find_gamut_intersection(
	a: number,
	b: number,
	L1: number,
	C1: number,
	L0: number
): number
	-- Find the cusp of the gamut triangle
	local L_cusp, C_cusp = Oklab.find_cusp(a, b)

	-- Find the intersection for upper and lower half seprately
	local t
	if ((L1 - L0) * C_cusp - (L_cusp - L0) * C1) <= 0 then
		-- Lower half
		t = C_cusp * L0 / (C1 * L_cusp + C_cusp * (L0 - L1))
	else
		-- Upper half
		-- First intersect with triangle
		t = C_cusp * (L0 - 1) / (C1 * (L_cusp - 1) + C_cusp * (L0 - L1))

		-- Then one step Halley's method
		do
			local dL = L1 - L0
			local dC = C1

			local k_l =  0.3963377774 * a + 0.2158037573 * b
			local k_m = -0.1055613458 * a - 0.0638541728 * b
			local k_s = -0.0894841775 * a - 1.2914855480 * b

			local l_dt = dL + dC * k_l
			local m_dt = dL + dC * k_m
			local s_dt = dL + dC * k_s

			-- If higher accuracy is required, 2 or 3 iterations of the following block can be used:
			do
				local L = L0 * (1 - t) + t * L1
				local C = t * C1

				local l_ = L + C * k_l
				local m_ = L + C * k_m
				local s_ = L + C * k_s

				local l = l_ * l_ * l_
				local m = m_ * m_ * m_
				local s = s_ * s_ * s_

				local ldt = 3 * l_dt * l_ * l_
				local mdt = 3 * m_dt * m_ * m_
				local sdt = 3 * s_dt * s_ * s_

				local ldt2 = 6 * l_dt * l_dt * l_
				local mdt2 = 6 * m_dt * m_dt * m_
				local sdt2 = 6 * s_dt * s_dt * s_

				local r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1
				local r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt
				local r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2

				local u_r = r1 / (r1 * r1 - 0.5 * r * r2)
				local t_r = -r * u_r

				local g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1
				local g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt
				local g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2

				local u_g = g1 / (g1 * g1 - 0.5 * g * g2)
				local t_g = -g * u_g

				local b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s - 1
				local b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt
				local b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2

				local u_b = b1 / (b1 * b1 - 0.5 * b * b2)
				local t_b = -b * u_b

				t_r = if u_r >= 0 then t_r else math.huge
				t_g = if u_g >= 0 then t_g else math.huge
				t_b = if u_b >= 0 then t_b else math.huge

				t += math.min(t_r, t_g, t_b)
			end
		end
	end

	return t
end

function Oklab.gamut_clip_preserve_chroma(
	rgb: Vector3
): Vector3
	if rgb.X < 1 and rgb.Y < 1 and rgb.Z < 1 and rgb.X > 0 and rgb.Y > 0 and rgb.Z > 0 then
		return rgb
	end

	local lab = Oklab.linear_srgb_to_oklab(rgb)

	local L = lab.X
	local eps = 0.00001
	local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z))
	local a_ = if C == 0 then 0 else lab.Y / C
	local b_ = if C == 0 then 0 else lab.Z / C
	local L0 = math.clamp(L, 0, 1)

	local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0)
	local L_clipped = L0 * (1 - t) + t * L
	local C_clipped = t * C

	return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_))
end

function Oklab.gamut_clip_project_to_0_5(
	rgb: Vector3
): Vector3
	if rgb.X < 1 and rgb.Y < 1 and rgb.Z < 1 and rgb.X > 0 and rgb.Y > 0 and rgb.Z > 0 then
		return rgb
	end

	local lab = Oklab.linear_srgb_to_oklab(rgb)

	local L = lab.X
	local eps = 0.00001
	local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z))
	local a_ = lab.Y / C
	local b_ = lab.Z / C

	local L0 = 0.5

	local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0)
	local L_clipped = L0 * (1 - t) + t * L
	local C_clipped = t * C

	return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_))
end

function Oklab.gamut_clip_project_to_L_cusp(
	rgb: Vector3
): Vector3
	if rgb.X < 1 and rgb.Y < 1 and rgb.Z < 1 and rgb.X > 0 and rgb.Y > 0 and rgb.Z > 0 then
		return rgb
	end

	local lab = Oklab.linear_srgb_to_oklab(rgb)

	local L = lab.X
	local eps = 0.00001
	local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z))
	local a_ = lab.Y / C
	local b_ = lab.Z / C

	-- The cusp is computed here and in find_gamut_intersection, an optimised solution would only compute it once.
	local L_cusp, C_cusp = Oklab.find_cusp(a_, b_)

	local L0 = L_cusp

	local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0)

	local L_clipped = L0 * (1 - t) + t * L
	local C_clipped = t * C

	return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_))
end

function Oklab.gamut_clip_adaptive_L0_0_5(
	rgb: Vector3,
	alpha: number?
): Vector3
	if rgb.X < 1 and rgb.Y < 1 and rgb.Z < 1 and rgb.X > 0 and rgb.Y > 0 and rgb.Z > 0 then
		return rgb
	end
	local alpha = alpha or 0.05

	local lab = Oklab.linear_srgb_to_oklab(rgb)

	local L = lab.X
	local eps = 0.00001
	local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z))
	local a_ = lab.Y / C
	local b_ = lab.Z / C

	local Ld = L - 0.5
	local e1 = 0.5 + math.abs(Ld) + alpha * C
	local L0 = 0.5 * (1 + math.sign(Ld) * (e1 - math.sqrt(e1*e1 - 2 * math.abs(Ld))))

	local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0)
	local L_clipped = L0 * (1 - t) + t * L
	local C_clipped = t * C

	return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_))
end

function Oklab.gamut_clip_adaptive_L0_L_cusp(
	rgb: Vector3,
	alpha: number?
): Vector3
	if rgb.X < 1 and rgb.Y < 1 and rgb.Z < 1 and rgb.X > 0 and rgb.Y > 0 and rgb.Z > 0 then
		return rgb
	end
	local alpha = alpha or 0.05

	local lab = Oklab.linear_srgb_to_oklab(rgb)

	local L = lab.X
	local eps = 0.00001
	local C = math.max(eps, math.sqrt(lab.Y * lab.Y + lab.Z * lab.Z))
	local a_ = lab.Y / C
	local b_ = lab.Z / C

	-- The cusp is computed here and in find_gamut_intersection, an optimized solution would only compute it once.
	local L_cusp, C_cusp = Oklab.find_cusp(a_, b_)

	local Ld = L - L_cusp
	local k = 2 * (if Ld > 0 then 1 - L_cusp else L_cusp)

	local e1 = 0.5*k + math.abs(Ld) + alpha * C/k
	local L0 = L_cusp + 0.5 * (math.sign(Ld) * (e1 - math.sqrt(e1 * e1 - 2 * k * math.abs(Ld))))

	local t = Oklab.find_gamut_intersection(a_, b_, L, C, L0)
	local L_clipped = L0 * (1 - t) + t * L
	local C_clipped = t * C

	return Oklab.oklab_to_linear_srgb(Vector3.new(L_clipped, C_clipped * a_, C_clipped * b_))
end

--[[
	ROBLOX EXTENSIONS
]]

Oklab.default_gamut_clip = Oklab.gamut_clip_adaptive_L0_0_5

local function component_to_gamma(x: number): number
	if x >= 0.0031308 then
		return (1.055) * x^(1.0/2.4) - 0.055
	else
		return 12.92 * x
	end
end

local function component_to_linear(x: number): number
	if x >= 0.04045 then
		return ((x + 0.055)/(1 + 0.055))^2.4
	else 
		return x / 12.92
	end
end

function Oklab.color3_to_linear_srgb(
	c: Color3
): Vector3
	return Vector3.new(
		component_to_linear(c.R),
		component_to_linear(c.G),
		component_to_linear(c.B)
	)
end

function Oklab.linear_srgb_to_color3(
	c: Vector3,
	use_default_gamut_clip: boolean?
): Color3
	if use_default_gamut_clip == false then
		return Color3.new(
			component_to_gamma(c.X),
			component_to_gamma(c.Y),
			component_to_gamma(c.Z)
		)
	else
		local c = Oklab.default_gamut_clip(c)
		return Color3.new(
			math.clamp(component_to_gamma(c.X), 0, 1),
			math.clamp(component_to_gamma(c.Y), 0, 1),
			math.clamp(component_to_gamma(c.Z), 0, 1)
		)
	end


end

--[[
	LCH EXTENSIONS
]]

function Oklab.oklch_to_oklab(oklch: Vector3): Vector3
	return Vector3.new(
		oklch.X,
		oklch.Y * math.cos(oklch.Z * TAU),
		oklch.Y * math.sin(oklch.Z * TAU)
	)
end

function Oklab.oklab_to_oklch(oklab: Vector3): Vector3
	return Vector3.new(
		oklab.X,
		math.sqrt(oklab.Y^2 + oklab.Z^2),
		(math.atan2(oklab.Z, oklab.Y) / TAU) % 1
	)
end

return Oklab