dripstone = {}

-- Internal values that cannot be changed by other mods (directly).
local internal = {
	-- These values are not meant to be changed during runtime.
	constant = {
		-- How many nodes downwards a droplet is able to drop from a stalactite
		-- before the droplet evaporates.
		drop_down_reach = 50,

		-- The number of seconds it takes for a dripstone node to grow 1 unit
		-- (NOTE: Not one node size! One unit, which quadratically increases
		-- per node size.)
		growth_factor = 3,

		-- This mod's name.
		modname = minetest.get_current_modname(),

		-- The number of samples that each ABM should execute.
		-- Make sure this is a whole number and less than speed_factor.
		samples_per_interval = 30,

		-- Factor deciding this mod's relative speed.
		-- Set this value to 1 if you wish to debug and let the dripstone
		-- change rapidly.
		-- Rule of thumb: with a setting of 60, it takes a lava farm about 30
		-- minutes to fill a cauldron with lava.
		speed_factor = 60,

		-- Names of the various dripstone widths
		width_names = {
			"spike", "tiny", "small", "medium",
			"great", "large", "huge", "block",
		},
	},

	-- Nodes that function as cauldrons
	cauldrons = {},

	-- Nodes that provide droplets
	sources = {},

	-- Nodes that allow a droplet to trickle down if it is directly below a
	-- node that passes down that droplet.
	tricklers = {},
}


-------------------------------------------------------------------------------
-------------------------------------------------------------------------------
---------------------------     PUBLIC API     --------------------------------
-------------------------------------------------------------------------------
-------------------------------------------------------------------------------

-- Register a node that can catch a droplet from a dripstone stalactite.
function dripstone.add_droplet_catcher(droplet, oldnodename, newnodename)
	return internal.add_droplet_catcher(droplet, oldnodename, newnodename)
end

-- Register a new source node that can provide droplets to dripstone blocks.
function dripstone.add_droplet_source(droplet, nodename)
	return internal.add_droplet_source(droplet, nodename)
end

-- Register a new dripstone type.
--
-- {
-- 		-- What item is dropped when the dripstone is broken.
--		-- When left nil, the spike of the dripstone type is dropped.
--		drop = "dry"
--
-- 		-- What flavor to become when using liquid to grow.
-- 		-- Leave to nil when unable to grow.
-- 		grow_to = "dry"
--
-- 		-- When receiving a droplet of a given type, transform into a different
-- 		-- dripstone type. When a droplet is unspecified, the block cannot
-- 		-- receive the droplet.
-- 		on_droplet_receive = {
-- 			water = "watered",
-- 			lava = "molten",
-- 		}
--
--		-- Sounds that the dripstone makes
--      sounds = <standard sound definition for a node>
--
-- 		-- Node tiles for layout
-- 		tiles = <node tile layout>
--
-- 		-- Droplet type that the dripstone flavor can pass down.
-- 		-- When the droplet is passed down, the dripstone converts to the
--		-- "grow_to" type
-- 		trickle_down = "water"
-- 
--  	-- Speed of how often a droplet trickles down.
-- 		trickle_speed = 5
-- }
function dripstone.register_dripstone(flavor, def)
	return internal.register_dripstone_flavor(flavor, def)
end

-- Register a new droplet type that can be absorbed and passed on by dripstone.
function dripstone.register_droplet(droplet)
	if internal.cauldrons[droplet] == nil then
		internal.cauldrons[droplet] = {}
	end
	if internal.sources[droplet] == nil then
		internal.sources[droplet] = {}
	end
	if internal.tricklers[droplet] == nil then
		internal.tricklers[droplet] = {}
	end
end

-- Get a dripstone's node name based on its flavor and size.
function dripstone.size_to_name(flavor, size)
	return internal.size_to_name(flavor, size)
end

-------------------------------------------------------------------------------
-------------------------------------------------------------------------------
-------------------------------------------------------------------------------
-------------------------------------------------------------------------------
-------------------------------------------------------------------------------

-- Add a droplet catcher, which is a node that allows a stalactite spike to
-- change the name using a droplet.
function internal.add_droplet_catcher(droplet, oldnodename, newnodename)
	return internal.register_cauldron(droplet, oldnodename, newnodename)
end

function internal.add_droplet_source(droplet, nodename)
	if internal.sources[droplet] == nil then
		internal.uninitialized_droplet_error(droplet)
	end
	table.insert(internal.sources[droplet], nodename)
end

-- Add a droplet trickler, which is a dripstone node that allows a droplet to
-- be trickled down from the node directly above it.
-- Running this function overrides previous values.
function internal.add_droplet_trickler(droplet, oldnodename, newnodename)
	if internal.tricklers[droplet] == nil then
		internal.uninitialized_droplet_error(droplet)
	end

	internal.tricklers[droplet][oldnodename] = newnodename
end

-- Capitalize a string
function internal.capitalize(str)
	return (str:gsub("^%l", string.upper))
end

function internal.drawtype_of_size(size)
	if size >= 8 then
		return "normal"
	else
		return "nodebox"
	end
end

function internal.hit_with_droplet(pos, node, droplet, spikename)
	local m = internal.cauldrons[droplet] or {}

	if m[node.name] == nil then
		-- Not a cauldron! Therefore we place a spike on top.
		pos = vector.offset(pos, 0, 1, 0)
		node = minetest.get_node(pos)
		node.name = spikename
		minetest.set_node(pos, node)
	else
		node.name = m[node.name]
		minetest.set_node(pos, node)
	end
end

-- Determine whether this mod considers a node an air node.
function internal.is_air(nodename)
	if nodename == "air" then
		return true
	else
		return minetest.get_item_group(nodename, "air") ~= 0
	end
end

-- Create a node box for any given dripstone size.
-- Size 8 is a normal block size
function internal.nodebox_of_size(size)
	if size >= 8 then
		return nil
	else
		return {
			type = "fixed",
			fixed = {
				{ - size / 16, -0.5, - size / 16, size / 16, 0.5, size / 16 },
			},
		}
	end
end

function internal.register_absorb_abm(droplet, oldnodename, newnodename)
	minetest.register_abm({
		nodenames = { oldnodename },
		interval = internal.constant.speed_factor / internal.constant.samples_per_interval,
		chance = internal.constant.samples_per_interval,
		catch_up = true,
		action = function (pos, node, aoc, aocw)
			local pos_above = vector.offset(pos, 0, 1, 0)
			local node_above = minetest.get_node(pos_above)

			for _, source in pairs(internal.sources[droplet] or {}) do
				if node_above.name == source then
					node.name = newnodename
					minetest.set_node(pos, node)
					return
				end
			end
		end
	})
end

function internal.register_cauldron(droplet, oldnodename, newnodename)
	if internal.cauldrons[droplet] == nil then
		internal.uninitialized_droplet_error(droplet)
	end

	internal.cauldrons[droplet][oldnodename] = newnodename
end

function internal.register_dripstone_craft(newnodename, oldnodename, spikename)
	minetest.register_craft({
		output = newnodename,
		recipe = {
			{ spikename, spikename  , spikename },
			{ spikename, oldnodename, spikename },
			{ spikename, spikename  , spikename },
		}
	})
end

function internal.register_dripstone_flavor(flavor, def)
	-- Guaranteed values
	local drop = def.drop or internal.size_to_name(flavor, 1)
	local on_droplet_receive = def.on_droplet_receive or {}
	local trickle_speed = def.trickle_speed or 1

	-- Potentially nil, might need to be checked before assumed safe
	local dry_up = def.grow_to
	local sounds = def.sounds
	local tiles = def.tiles
	local trickl = def.trickle_down

	-- Register nodes
	for width = 1, 8, 1 do
		internal.register_dripstone_node(flavor, width, tiles, sounds, drop)
	end

	-- Register upgrade crafting recipes
	for width = 1, 6, 1 do
		internal.register_dripstone_craft(
			internal.size_to_name(flavor, width + 1),
			internal.size_to_name(flavor, width),
			internal.size_to_description(flavor, 1)
		)
	end

	-- Allow dripstone nodes to trickle down droplets
	for droplet, new_flavor in pairs(on_droplet_receive) do
		for width = 1, 8, 1 do
			internal.add_droplet_trickler(
				droplet,
				internal.size_to_name(flavor, width),
				internal.size_to_name(new_flavor, width)
			)
		end
	end

	-- Allow spike stalagmites to catch droplets
	for droplet, new_flavor in pairs(on_droplet_receive) do
		internal.register_cauldron(
			droplet,
			internal.size_to_name(flavor, 1),
			internal.size_to_name(new_flavor, 1)
		)
	end

	-- Register ABM to grow when possible.
	if dry_up then
		for width = 1, 6, 1 do
			internal.register_grow_abm(
				internal.size_to_name(flavor, width),
				internal.size_to_name(dry_up, width + 1),
				width
			)
		end
	end

	-- Register ABM to grow when possible
	if trickl and dry_up then
		for width = 1, 8, 1 do
			internal.register_trickle_down_abm(
				trickl,
				width,
				internal.size_to_name(flavor, width),
				internal.size_to_name(dry_up, width),
				dry_up,
				trickle_speed
			)
		end
	end

	-- Register ABM to absorb liquids from above
	for droplet, new_flavor in pairs(on_droplet_receive) do
		internal.register_absorb_abm(
			droplet,
			internal.size_to_name(flavor, 8),
			internal.size_to_name(new_flavor, 8)
		)
	end

	-- Register ABM to drop down droplets from a stalactite spike
	if dry_up and trickl then
		internal.register_drop_down_abm(
			trickl,
			internal.size_to_name(flavor, 1),
			internal.size_to_name(dry_up, 1),
			trickle_speed
		)

		-- Makes dripstone stalagmite spikes delete droplets.
		-- Without this, stalactites remain very thick and short while
		-- stalagmites become absurdly long and thin.
		-- A watered stalagmite can't accept a water droplet and the stalagmite
		-- therefore grows one per droplet. To mitigate this, a watered spike
		-- can still act as a water droplet cauldron without changing.
		-- This way, no new droplets are passed on if the stalagmite is already
		-- full, and the structure simply waits for a dripstone node to grow.
		-- This behaviour is designed to be easy to override. (For example: if
		-- you want a HEAVY watered dripstone type that holds 2 droplets.)
		internal.add_droplet_catcher(
			trickl,
			internal.size_to_name(flavor, 1),
			internal.size_to_name(flavor, 1)
		)
	end
end

function internal.register_dripstone_node(flavor, size, tiles, sounds, drop)
	minetest.register_node(internal.size_to_name(flavor, size), {
		description = internal.size_to_description(flavor, size),
		tiles = tiles,
		groups = {
			pickaxey=2,
			material_stone=1,
			fall_damage_add_percent = math.max(4 - size, 0) / 4 * 100
		},
		is_ground_content = true,
		drop = {
			max_items = math.floor((size + 1) / 2),
			items = {
				{ rarity = 1
				, items = { drop }
				},
				{ rarity = 2
				, items = { drop }
				},
				{ rarity = 4
				, items = { drop }
				},
				{ rarity = 4
				, items = { drop }
				},
			}
		},
		sounds = sounds,
		drawtype = internal.drawtype_of_size(size),
		paramtype = "light",
		sunlight_propagates = size < 8,
		node_box = internal.nodebox_of_size(size),
		_mcl_hardness = 1.0 + size / 8,
		_mcl_blast_resistance = 1 + size / 2,
		_mcl_silk_touch_drop = true,
	})
end

function internal.register_drop_down_abm(droplet, spikename, dryspikename, trickle_speed)
	minetest.register_abm({
		nodenames = { spikename },
		interval = trickle_speed * internal.constant.speed_factor / internal.constant.samples_per_interval,
		chance = internal.constant.samples_per_interval,
		catch_up = true,
		action = function (pos, node, aoc, aocw)
			local pos_below = vector.offset(pos, 0, -1, 0)
			local node_below = minetest.get_node(pos_below)

			if not internal.is_air(node_below.name) then
				-- Node below is not air! Unable to drop a droplet down.
				return
			end

			for dy = 2, internal.constant.drop_down_reach, 1 do
				pos_below = vector.offset(pos, 0, -dy, 0)
				node_below = minetest.get_node(pos_below)

				if not internal.is_air(node_below.name) then
					-- Node is not air! If it is a cauldron, update the node.
					internal.hit_with_droplet(
						pos_below,
						node_below,
						droplet,
						dryspikename
					)
					break
				end
			end

			node.name = dryspikename
			minetest.set_node(pos, node)
		end
	})
end

function internal.register_grow_abm(oldnodename, newnodename, width)
	minetest.register_abm({
		nodenames = { oldnodename },
		-- 2(w + 1) * 2(w + 1) - 2w * 2w = 8w + 4
		interval = (8 * width + 4) * internal.constant.speed_factor * internal.constant.growth_factor / internal.constant.samples_per_interval,
		chance = internal.constant.samples_per_interval,
		catch_up = true,
		action = function (pos, node, aoc, aocw)
			node.name = newnodename
			minetest.set_node(pos, node)
		end
	})
end

function internal.register_trickle_down_abm(droplet, width, old_source, new_source, dry_up, trickle_speed)
	minetest.register_abm({
		nodenames = { old_source },
		interval = trickle_speed * internal.constant.speed_factor / internal.constant.samples_per_interval,
		chance = internal.constant.samples_per_interval,
		catch_up = true,
		action = function (pos, node, aoc, aocw)
			local pos_below = vector.offset(pos, 0, -1, 0)
			local node_below = minetest.get_node(pos_below)

			local m = internal.tricklers[droplet] or {}

			if m[node_below.name] ~= nil then
				-- Trickler found below!
				node_below.name = m[node_below.name]
			elseif width > 1 and internal.is_air(node_below.name) then
				-- Air node found below a non-spike, turn it into a spike.
				node_below.name = internal.size_to_name(dry_up, 1)
			else
				return -- Prevent droplet from leaking away
			end
			
			node.name = new_source
			minetest.set_node(pos_below, node_below)
			minetest.set_node(pos, node)
		end
	})
end

function internal.size_to_description(flavor, size)
	local width_name = internal.constant.width_names[size]

	if size == 1 or size == 8 then
		return internal.capitalize(flavor) .. " dripstone " .. width_name
	else
		return internal.capitalize(width_name) .. " " .. flavor .. " dripstone"
	end
end

function internal.size_to_name(flavor, size)
	local namespace = internal.constant.modname .. ":"
	local width_name = internal.constant.width_names[size]

	if size == 1 or size == 8 then
		return namespace .. flavor .. "_dripstone_" .. width_name
	else
		return namespace .. width_name .. "_" .. flavor .. "_dripstone"
	end
end

function internal.uninitialized_droplet_error(droplet)
	error(
		"Droplet " .. droplet .. " has not been initialized yet!"
	)
end
