--[[

	TechAge
	=======

	Copyright (C) 2019-2023 Joachim Stolberg

	AGPL v3
	See LICENSE.txt for more information

	A state model/class for TechAge nodes.

]]--


--[[

Node states:

        +-----------------------------------+    +------------+
        |                                   |    |            |
        |                                   V    V            |
        |                                +---------+          |
        |                                |         |          |
        |                      +---------| STOPPED |          |
        |                      |         |         |          |
        |               button |         +---------+          |
        |                      |              ^               |
 button |                      V              | button        |
        |                 +---------+         |               | button
        |      +--------->|         |---------+               |
        |      | power    | RUNNING |                         |
        |      |   +------|         |---------+               |
        |      |   |      +---------+         |               |
        |      |   |         ^    |           |               |
        |      |   |         |    |           |               |
        |      |   V         |    V           V               |
        |   +---------+   +----------+   +---------+          |
        |   |         |   |          |   |         |          |
        +---| NOPOWER |   | STANDBY/ |   |  FAULT  |----------+
            |         |   | BLOCKED  |   |         |
            +---------+   +----------+   +---------+


	|           cycle time   operational   needs power
	+---------+------------+-------------+-------------
	| RUNNING    normal         yes           yes
	| BLOCKED    long           yes           no
	| STANDBY    long           yes           no
	| NOPOWER    long           no            no
	| FAULT      none           no            no
	| STOPPED    none           no            no

Node nvm data:
	"techage_state"      - node state, like "RUNNING"
	"techage_countdown"  - countdown to standby mode
]]--

-- for lazy programmers
local S = function(pos) if pos then return minetest.pos_to_string(pos) end end
local P = minetest.string_to_pos
local M = minetest.get_meta
local N = techage.get_node_lvm

local MAX_CYCLE_TIME = 20

--
-- TechAge machine states
--

techage.RUNNING  = 1  -- in normal operation/turned on
techage.BLOCKED  = 2  -- a pushing node is blocked due to a full destination inventory
techage.STANDBY  = 3  -- nothing to do (e.g. no input items), or node (world) not loaded
techage.NOPOWER  = 4  -- only for power consuming nodes, no operation
techage.FAULT    = 5  -- any fault state (e.g. wrong source items), which can be fixed by the player
techage.STOPPED  = 6  -- not operational/turned off
techage.UNLOADED = 7  -- Map block unloaded
techage.INACTIVE = 8  -- Map block loaded but node is not actively working

techage.StatesImg = {
	"techage_inv_button_on.png",
	"techage_inv_button_warning.png",
	"techage_inv_button_standby.png",
	"techage_inv_button_nopower.png",
	"techage_inv_button_error.png",
	"techage_inv_button_off.png",
}

local function error(pos, msg)
	minetest.log("error", "[TA states] "..msg.." at "..S(pos).." "..N(pos).name)
end

-- Return state button image for the node inventory
function techage.state_button(state)
	if state and state < 7 and state > 0 then
		return techage.StatesImg[state]
	end
	return "techage_inv_button_off.png"
end

function techage.get_power_image(pos, nvm)
	local node = techage.get_node_lvm(pos)
	local s = "3" -- electrical power
	if string.find(node.name, "techage:ta2") then
		s = "2"  -- axles power
	end
	return "techage_inv_powerT"..s..".png"
end

-- State string based on button states
techage.StateStrings = {"running", "blocked", "standby", "nopower", "fault", "stopped"}

--
-- Local States
--
local RUNNING = techage.RUNNING
local BLOCKED = techage.BLOCKED
local STANDBY = techage.STANDBY
local NOPOWER = techage.NOPOWER
local FAULT   = techage.FAULT
local STOPPED = techage.STOPPED


--
-- NodeStates Class Functions
--
techage.NodeStates = {}
local NodeStates = techage.NodeStates

local function can_start(pos, nvm)
	--if false, node goes in FAULT
	return true
end

local function has_power(pos, nvm)
	--if false, node goes in NOPOWER
	return true
end

local function swap_node(pos, new_name, old_name)
	local node = techage.get_node_lvm(pos)
	if node.name == new_name then
		return
	end
	if node.name == old_name then
		node.name = new_name
		minetest.swap_node(pos, node)
	end
end

-- true if node_timer should be executed
function techage.is_operational(nvm)
	local state = nvm.techage_state or STOPPED
	return state < NOPOWER
end

function techage.is_running(nvm)
	return (nvm.techage_state or STOPPED) == RUNNING
end

-- consumes power
function techage.needs_power(nvm)
	local state = nvm.techage_state or STOPPED
	return state == RUNNING or state == NOPOWER
end

-- consumes power
function techage.needs_power2(state)
	state = state or STOPPED
	return state == RUNNING or state == NOPOWER
end

function techage.get_state_string(nvm)
	return techage.StateStrings[nvm.techage_state or STOPPED]
end

function NodeStates:new(attr)
	local o = {
		-- mandatory
		cycle_time = attr.cycle_time, -- for running state
		standby_ticks = attr.standby_ticks, -- for standby state
		-- optional
		countdown_ticks = attr.countdown_ticks or 1,
		node_name_passive = attr.node_name_passive,
		node_name_active = attr.node_name_active,
		infotext_name = attr.infotext_name,
		has_power =  attr.has_power or has_power,
		can_start = attr.can_start or can_start,
		start_node = attr.start_node,
		stop_node = attr.stop_node,
		formspec_func = attr.formspec_func,
		on_state_change = attr.on_state_change,
		quick_start = attr.quick_start,
	}
	setmetatable(o, self)
	self.__index = self
	return o
end

function NodeStates:node_init(pos, nvm, number)
	nvm.techage_state = STOPPED
	M(pos):set_string("node_number", number)
	if self.infotext_name then
		M(pos):set_string("infotext", self.infotext_name.." "..number..": stopped")
	end
	if self.formspec_func then
		M(pos):set_string("formspec", self.formspec_func(self, pos, nvm))
	end
end

-- to be used to re-start the timer outside of node_timer()
local function start_timer_delayed(pos, cycle_time)
	local t = minetest.get_node_timer(pos)
	t:stop()
	if cycle_time > 0.9 then
		minetest.after(0.1, t.start, t, cycle_time)
	else
		error(pos, "invalid cycle_time")
	end
end

function NodeStates:stop(pos, nvm)
	local state = nvm.techage_state or STOPPED
	nvm.techage_state = STOPPED
	if self.stop_node then
		self.stop_node(pos, nvm, state)
	end
	if self.node_name_passive then
		swap_node(pos, self.node_name_passive, self.node_name_active)
	end
	if self.infotext_name then
		local number = M(pos):get_string("node_number")
		M(pos):set_string("infotext", self.infotext_name.." "..number..": stopped")
	end
	if self.formspec_func then
		nvm.ta_state_tooltip = "stopped"
		M(pos):set_string("formspec", self.formspec_func(self, pos, nvm))
	end
	if self.on_state_change then
		self.on_state_change(pos, state, STOPPED)
	end
	if minetest.get_node_timer(pos):is_started() then
		minetest.get_node_timer(pos):stop()
	end
	return true
end

function NodeStates:start(pos, nvm)
	local state = nvm.techage_state or STOPPED
	if state ~= RUNNING and state ~= FAULT then
		local res = self.can_start(pos, nvm, state)
		if res ~= true then
			self:fault(pos, nvm, res)
			return false
		end
		if not self.has_power(pos, nvm, state) then
			self:nopower(pos, nvm)
			return false
		end
		nvm.techage_state = RUNNING
		if self.start_node then
			self.start_node(pos, nvm, state)
		end
		nvm.techage_countdown = self.countdown_ticks
		if self.node_name_active then
			swap_node(pos, self.node_name_active, self.node_name_passive)
		end
		if self.infotext_name then
			local number = M(pos):get_string("node_number")
			M(pos):set_string("infotext", self.infotext_name.." "..number..": running")
		end
		if self.formspec_func then
			nvm.ta_state_tooltip = "running"
			M(pos):set_string("formspec", self.formspec_func(self, pos, nvm))
		end
		if minetest.get_node_timer(pos):is_started() then
			minetest.get_node_timer(pos):stop()
		end
		if self.on_state_change then
			self.on_state_change(pos, state, RUNNING)
		end
		start_timer_delayed(pos, self.cycle_time)

		if self.quick_start and state == STOPPED then
			self.quick_start(pos, 0)
		end
		self:trigger_state(pos, nvm)
		return true
	end
	return false
end

function NodeStates:standby(pos, nvm, err_string)
	local state = nvm.techage_state or STOPPED
	if state == RUNNING or state == BLOCKED then
		nvm.techage_state = STANDBY
		if self.node_name_passive then
			swap_node(pos, self.node_name_passive, self.node_name_active)
		end
		if self.infotext_name then
			local number = M(pos):get_string("node_number")
			M(pos):set_string("infotext", self.infotext_name.." "..number..": "..(err_string or "standby"))
		end
		if self.formspec_func then
			nvm.ta_state_tooltip = err_string or "standby"
			M(pos):set_string("formspec", self.formspec_func(self, pos, nvm))
		end
		if self.on_state_change then
			self.on_state_change(pos, state, STANDBY)
		end
		start_timer_delayed(pos, self.cycle_time * self.standby_ticks)
		return true
	end
	return false
end

-- special case of standby for pushing nodes
function NodeStates:blocked(pos, nvm, err_string)
	local state = nvm.techage_state or STOPPED
	if state == RUNNING then
		nvm.techage_state = BLOCKED
		if self.node_name_passive then
			swap_node(pos, self.node_name_passive, self.node_name_active)
		end
		if self.infotext_name then
			local number = M(pos):get_string("node_number")
			M(pos):set_string("infotext", self.infotext_name.." "..number..": "..(err_string or "blocked"))
		end
		if self.formspec_func then
			nvm.ta_state_tooltip = err_string or "blocked"
			M(pos):set_string("formspec", self.formspec_func(self, pos, nvm))
		end
		if self.on_state_change then
			self.on_state_change(pos, state, BLOCKED)
		end
		start_timer_delayed(pos, self.cycle_time * self.standby_ticks)
		return true
	end
	return false
end

function NodeStates:nopower(pos, nvm, err_string)
	local state = nvm.techage_state or RUNNING
	if state ~= NOPOWER then
		nvm.techage_state = NOPOWER
		if self.node_name_passive then
			swap_node(pos, self.node_name_passive, self.node_name_active)
		end
		if self.infotext_name then
			local number = M(pos):get_string("node_number")
			M(pos):set_string("infotext", self.infotext_name.." "..number..": "..(err_string or "no power"))
		end
		if self.formspec_func then
			nvm.ta_state_tooltip = err_string or "no power"
			M(pos):set_string("formspec", self.formspec_func(self, pos, nvm))
		end
		if self.on_state_change then
			self.on_state_change(pos, state, NOPOWER)
		end
		start_timer_delayed(pos, self.cycle_time * self.standby_ticks)
		return true
	end
	return false
end

function NodeStates:fault(pos, nvm, err_string)
	local state = nvm.techage_state or STOPPED
	err_string = err_string or "fault"
	if state == RUNNING or state == STOPPED then
		nvm.techage_state = FAULT
		if self.node_name_passive then
			swap_node(pos, self.node_name_passive, self.node_name_active)
		end
		if self.infotext_name then
			local number = M(pos):get_string("node_number")
			M(pos):set_string("infotext", self.infotext_name.." "..number..": "..err_string)
		end
		if self.formspec_func then
			nvm.ta_state_tooltip = err_string or "fault"
			M(pos):set_string("formspec", self.formspec_func(self, pos, nvm))
		end
		if self.on_state_change then
			self.on_state_change(pos, state, FAULT)
		end
		minetest.get_node_timer(pos):stop()
		return true
	end
	return false
end

function NodeStates:get_state(nvm)
	return nvm.techage_state or techage.STOPPED
end

-- keep the timer running?
function NodeStates:is_active(nvm)
	local state = nvm.techage_state or STOPPED
	return state < FAULT
end

function NodeStates:start_if_standby(pos)
	local nvm = techage.get_nvm(pos)
	if nvm.techage_state == STANDBY then
		self:start(pos, nvm)
	end
end

-- To be called if node is idle.
-- If countdown reaches zero, the node is set to STANDBY.
function NodeStates:idle(pos, nvm)
	local countdown = (nvm.techage_countdown or 0) - 1
	nvm.techage_countdown = countdown
	if countdown <= 0 then
		self:standby(pos, nvm)
	end
end

-- To be called after successful node action to raise the timer
-- and keep the node in state RUNNING
function NodeStates:keep_running(pos, nvm, val)
	-- set to RUNNING if not already done
	if nvm.techage_state ~= RUNNING then
		self:start(pos, nvm)
	end
	nvm.techage_countdown = val or 4
	nvm.last_active = minetest.get_gametime()
end

function NodeStates:trigger_state(pos, nvm)
	nvm.last_active = minetest.get_gametime()
end

-- Start/stop node based on button events.
-- if function returns false, no button was pressed
function NodeStates:state_button_event(pos, nvm, fields)
	if fields.state_button ~= nil then
		local state = nvm.techage_state or STOPPED
		if state == STOPPED or state == STANDBY or state == BLOCKED then
			if not self:start(pos, nvm) and (state == STANDBY or state == BLOCKED) then
				self:stop(pos, nvm)
			end
		elseif state == RUNNING or state == FAULT or state == NOPOWER then
			self:stop(pos, nvm)
		end
		return true
	end
	return false
end

function NodeStates:get_state_button_image(nvm)
	local state = nvm.techage_state or STOPPED
	return techage.state_button(state)
end

function NodeStates:get_state_tooltip(nvm)
	local tp = nvm.ta_state_tooltip or ""
	return tp..";#0C3D32;#FFFFFF"
end

-- command interface
function NodeStates:on_receive_message(pos, topic, payload)
	local nvm = techage.get_nvm(pos)
	if topic == "on" then
		self:start(pos, techage.get_nvm(pos))
		return true
	elseif topic == "off" then
		self:stop(pos, techage.get_nvm(pos))
		return true
	elseif topic == "state" then
		local node = minetest.get_node(pos)
		if node.name == "ignore" then  -- unloaded node?
			return "unloaded"
		elseif nvm.techage_state == RUNNING then
			local ttl = (nvm.last_active or 0) + MAX_CYCLE_TIME
			if ttl < minetest.get_gametime() then
				return "inactive"
			end
		end
		return techage.get_state_string(techage.get_nvm(pos))
	elseif topic == "fuel" then
		return techage.fuel.get_fuel_amount(nvm)
	elseif topic == "load" then
		return techage.liquid.get_liquid_amount(nvm)
	else
		return "unsupported"
	end
end

function NodeStates:on_beduino_receive_cmnd(pos, topic, payload)
	if topic == 1 then
		if payload[1] == 0 then
			self:stop(pos, techage.get_nvm(pos))
			return 0
		else
			self:start(pos, techage.get_nvm(pos))
			return 0
		end
	else
		return 2  -- unknown or invalid topic
	end
end

function NodeStates:on_beduino_request_data(pos, topic, payload)
	local nvm = techage.get_nvm(pos)
	if topic == 128 then
		return 0, techage.get_node_lvm(pos).name
	elseif topic == 129 then
		local node = minetest.get_node(pos)
		if node.name == "ignore" then  -- unloaded node?
			return 0, {techage.UNLOADED}
		elseif nvm.techage_state == RUNNING then
			local ttl = (nvm.last_active or 0) + MAX_CYCLE_TIME
			if ttl < minetest.get_gametime() then
				return 0, {techage.INACTIVE}
			end
		end
		return 0, {nvm.techage_state or STOPPED}
	else
		return 2, ""  -- topic is unknown or invalid
	end
end

function NodeStates.get_beduino_state(pos)
	local node = minetest.get_node(pos)
	local nvm = techage.get_nvm(pos)
	if node.name == "ignore" then  -- unloaded node?
		return 0, {techage.UNLOADED}
	elseif nvm.techage_state == RUNNING then
		local ttl = (nvm.last_active or 0) + MAX_CYCLE_TIME
		if ttl < minetest.get_gametime() then
			return 0, {techage.INACTIVE}
		end
	end
	return 0, {nvm.techage_state or STOPPED}
end

-- restart timer
function NodeStates:on_node_load(pos)
	local nvm = techage.get_nvm(pos)
	local state = nvm.techage_state or STOPPED
	if state == RUNNING then
		minetest.get_node_timer(pos):start(self.cycle_time)
	elseif state < FAULT then
		minetest.get_node_timer(pos):start(self.cycle_time * self.standby_ticks)
	end
end

minetest.register_node("techage:defect_dummy", {
	description = "Corrupted Node (to be replaced)",
	tiles = {
		"techage_filling_ta2.png^techage_frame_ta2.png",
		"techage_filling_ta2.png^techage_frame_ta2.png",
		"techage_filling_ta2.png^techage_frame_ta2.png^techage_appl_defect.png",
		"techage_filling_ta2.png^techage_frame_ta2.png^techage_appl_defect.png",
		"techage_filling_ta2.png^techage_frame_ta2.png^techage_appl_defect.png",
		"techage_filling_ta2.png^techage_frame_ta2.png^techage_appl_defect.png",
	},
	drop = "",
	groups = {cracky=2, crumbly=2, choppy=2, not_in_creative_inventory=1},
	is_ground_content = false,
})
