#!lua name=rulesrv

-- ========================================
-- VoltageEMS Rule Service Functions
-- Version: 1.0.0
-- ========================================

-- Redis 8.0+ has cjson as a global
-- No need to declare, just use cjson directly

-- Safe JSON decode helper (unified error handling)
local function safe_decode(json_str, what)
	if not json_str then
		return nil, redis.error_reply("Missing " .. (what or "JSON"))
	end
	local ok, result = pcall(cjson.decode, json_str)
	if not ok then
		return nil, redis.error_reply("Invalid " .. (what or "JSON") .. ": " .. tostring(result))
	end
	return result
end

-- ============ Modsrv Field Parsing ============

-- Parse modsrv field reference (e.g., "pv_inverter_01.M.3")
local function parse_modsrv_field(field)
	local parts = {}
	for part in string.gmatch(field, "[^%.]+") do
		table.insert(parts, part)
	end

	if #parts ~= 3 then
		return nil
	end

	local instance_id = parts[1]
	local point_type = parts[2]
	local point_id = tonumber(parts[3])

	-- Validate point type
	if point_type ~= "M" and point_type ~= "A" then
		return nil
	end

	if not point_id then
		return nil
	end

	return {
		instance_id = instance_id,
		point_type = point_type,
		point_id = point_id,
		hash_key = string.format("modsrv:%s:%s", instance_id, point_type),
		field = tostring(point_id),
	}
end

-- Parse aggregate function (e.g., "SUM(pv_inverter_*.M.3)")
local function parse_aggregate_field(field)
	local func_name, field_expr = string.match(field, "^(%w+)%(([^%)]+)%)$")
	if not func_name or not field_expr then
		return nil
	end

	-- Convert function name to uppercase
	func_name = string.upper(func_name)

	-- Validate function
	local valid_funcs = { SUM = true, AVG = true, MAX = true, MIN = true, COUNT = true }
	if not valid_funcs[func_name] then
		return nil
	end

	-- Parse field expression
	local parts = {}
	for part in string.gmatch(field_expr, "[^%.]+") do
		table.insert(parts, part)
	end

	if #parts ~= 3 then
		return nil
	end

	return {
		func = func_name,
		instance_pattern = parts[1],
		point_type = parts[2],
		point_id = tonumber(parts[3]),
	}
end

-- ============ Modsrv Data Functions ============

-- Get modsrv instance data
-- @function rulesrv_get_modsrv_data
-- @input args[1]: instance_id - Instance identifier
-- @input args[2]: point_type (optional) - M|A (default: M)
-- @output JSON object with point_id:value pairs
-- @redis-read modsrv:{instance}:{type}:* - All instance points
-- @side-effects None (read-only operation)
local function rulesrv_get_modsrv_data(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: instance_id [point_type]")
	end

	local instance_id = args[1]
	local point_type = args[2] or "M" -- Default to measurement points

	-- Get all points for the instance using SCAN (avoid KEYS for production)
	local pattern = string.format("modsrv:%s:%s:*", instance_id, point_type)
	local cursor = "0"
	local result = {}

	repeat
		local res = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 100)
		cursor = res[1]
		local found_keys = res[2]

		for _, key in ipairs(found_keys) do
			local value = redis.call("GET", key)
			if value then
				-- Extract point_id from key
				local point_id = string.match(key, ":(%d+)$")
				if point_id then
					result[point_id] = value
				end
			end
		end
	until cursor == "0"

	return cjson.encode(result)
end

-- Evaluate modsrv field value
-- @function rulesrv_get_field_value
-- @input args[1]: field_expression - Field path or aggregate expression
-- @output String value or aggregate result
-- @redis-read modsrv:* - Instance data based on expression
-- @supports SUM(), AVG(), MIN(), MAX() aggregates
local function rulesrv_get_field_value(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: field_expression")
	end

	local field = args[1]

	-- Check if it's an aggregate function
	local agg = parse_aggregate_field(field)
	if agg then
		-- Find matching instance Hash keys using SCAN
		local pattern = string.format("modsrv:%s:%s", string.gsub(agg.instance_pattern, "%*", "*"), agg.point_type)

		local cursor = "0"
		local values = {}

		repeat
			local res = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 100)
			cursor = res[1]
			local matched_keys = res[2]

			for _, hash_key in ipairs(matched_keys) do
				-- Get the specific point value from the Hash
				local value = redis.call("HGET", hash_key, tostring(agg.point_id))
				if value then
					local num = tonumber(value)
					if num then
						table.insert(values, num)
					end
				end
			end
		until cursor == "0"

		-- Apply aggregate function
		if #values == 0 then
			return "0"
		end

		local result = 0
		if agg.func == "SUM" then
			for _, v in ipairs(values) do
				result = result + v
			end
		elseif agg.func == "AVG" then
			local sum = 0
			for _, v in ipairs(values) do
				sum = sum + v
			end
			result = sum / #values
		elseif agg.func == "MAX" then
			result = values[1]
			for _, v in ipairs(values) do
				if v > result then
					result = v
				end
			end
		elseif agg.func == "MIN" then
			result = values[1]
			for _, v in ipairs(values) do
				if v < result then
					result = v
				end
			end
		elseif agg.func == "COUNT" then
			result = #values
		end

		return tostring(result)
	end

	-- Check if it's a modsrv field
	local modsrv = parse_modsrv_field(field)
	if modsrv then
		local value = redis.call("HGET", modsrv.hash_key, modsrv.field)
		return value or ""
	end

	-- Try as legacy field (direct Redis key)
	local value = redis.call("GET", field)
	if value then
		return value
	end

	-- Try as hash field (format: key:field)
	local colon_pos = string.find(field, ":[^:]+$")
	if colon_pos then
		local key = string.sub(field, 1, colon_pos - 1)
		local hash_field = string.sub(field, colon_pos + 1)
		value = redis.call("HGET", key, hash_field)
		if value then
			return value
		end
	end

	return ""
end

-- ============ Condition Evaluation ============

-- Evaluate a condition
-- @function rulesrv_evaluate_condition
-- @input args[1]: condition_json - Condition definition
-- @output Boolean result as string ("true" or "false")
-- @redis-read Dynamic based on condition fields
-- @side-effects None (evaluation only)
local function rulesrv_evaluate_condition(keys, args)
	if #args < 3 then
		return redis.error_reply("Usage: field operator value")
	end

	local field = args[1]
	local operator = args[2]
	local compare_value = args[3]

	-- Get field value
	local field_value = rulesrv_get_field_value({}, { field })

	-- Convert to numbers if possible
	local left_num = tonumber(field_value)
	local right_num = tonumber(compare_value)

	local result = false

	if operator == ">" then
		if left_num and right_num then
			result = left_num > right_num
		end
	elseif operator == "<" then
		if left_num and right_num then
			result = left_num < right_num
		end
	elseif operator == ">=" then
		if left_num and right_num then
			result = left_num >= right_num
		end
	elseif operator == "<=" then
		if left_num and right_num then
			result = left_num <= right_num
		end
	elseif operator == "==" then
		if left_num and right_num then
			result = left_num == right_num
		else
			result = field_value == compare_value
		end
	elseif operator == "!=" then
		if left_num and right_num then
			result = left_num ~= right_num
		else
			result = field_value ~= compare_value
		end
	elseif operator == "in" then
		-- Check if value is in comma-separated list
		for item in string.gmatch(compare_value, "[^,]+") do
			if field_value == item then
				result = true
				break
			end
		end
	end

	return result and "true" or "false"
end

-- ============ Action Execution ============

-- Internal helper: Route action to comsrv (copied from modsrv.lua since cross-library calls not allowed)
-- This function handles the complete routing logic: mapping lookup, value processing, and dual write (TODO + Hash)
local function route_action_to_comsrv(instance_id, point_id, value)
	-- Build modsrv key for mapping lookup
	local modsrv_key = string.format("modsrv:%s:A:%s", instance_id, point_id)

	-- Look up reverse mapping
	local comsrv_key = redis.call("HGET", "map:m2c", modsrv_key)
	if not comsrv_key then
		return false, "NO_MAPPING"
	end

	-- Parse comsrv key to extract channel info
	local channel_id, point_type, comsrv_point_id = string.match(comsrv_key, "comsrv:(%d+):([CA]):(%d+)")
	if not channel_id then
		return false, "INVALID_KEY_FORMAT"
	end

	-- Ensure value is numeric
	local value_to_store = tonumber(value)
	if not value_to_store then
		return false, "INVALID_VALUE"
	end

	-- For Control type, ensure it's 0 or 1
	if point_type == "C" then
		value_to_store = (value_to_store ~= 0) and 1 or 0
	end

	-- CRITICAL: First TODO (trigger execution), then Hash (state storage)
	-- This ensures control action priority over state recording

	-- 1. Add to ComSrv command queue for execution (trigger)
	local queue_key = string.format("comsrv:%s:%s:TODO", channel_id, point_type)
	local time_now = redis.call("TIME")
	local timestamp_ms = time_now[1] * 1000 + math.floor(time_now[2] / 1000)
	local control_data = cjson.encode({
		command_id = string.format("rulesrv_%d_%d", timestamp_ms, math.random(100000)),
		channel_id = tonumber(channel_id),
		command_type = point_type, -- "C" or "A"
		point_id = tonumber(comsrv_point_id),
		value = value_to_store,
		timestamp = timestamp_ms, -- Use milliseconds consistently
		metadata = {},
		source = "rulesrv",
	})

	-- Check queue length before adding to detect potential issues
	local queue_len = redis.call("LLEN", queue_key)
	if queue_len >= 500 then
		-- Log warning to stats (comsrv likely has issues if this happens)
		redis.call("HINCRBY", "rulesrv:stats:warnings", "queue_overflow_" .. channel_id .. "_" .. point_type, 1)
		redis.call("HSET", "rulesrv:stats:last_overflow", channel_id .. "_" .. point_type, timestamp_ms)
		-- Continue execution but trim oldest items to maintain queue size
	elseif queue_len >= 450 then
		-- Early warning when approaching limit
		redis.call("HINCRBY", "rulesrv:stats:warnings", "queue_high_" .. channel_id .. "_" .. point_type, 1)
	end

	-- FIFO: RPUSH producer, BLPOP consumer
	redis.call("RPUSH", queue_key, control_data)
	-- Control queue length to avoid unbounded growth
	redis.call("LTRIM", queue_key, -500, -1) -- Keep only the newest 500 items (tail)

	-- 2. Update comsrv Hash (state storage)
	local comsrv_hash_key = string.format("comsrv:%s:%s", channel_id, point_type)
	redis.call("HSET", comsrv_hash_key, comsrv_point_id, tostring(value_to_store))

	return true, channel_id
end

-- Execute modsrv action
-- @function rulesrv_execute_modsrv_action
-- @input args[1]: action_json - Action definition
-- @output Execution result JSON
-- @redis-write modsrv:{instance}:A:{point} - Action state
-- @redis-write comsrv TODO queue via routing
-- @side-effects Triggers write-triggers-routing pattern
local function rulesrv_execute_modsrv_action(keys, args)
	if #args < 4 then
		return redis.error_reply("Usage: instance_id point_type point_id value")
	end

	local instance_id = args[1]
	local point_type = args[2]
	local point_id = args[3]
	local value = args[4]

	-- Validate point type
	if point_type ~= "M" and point_type ~= "A" then
		return redis.error_reply("Invalid point type: " .. point_type)
	end

	-- For action points, first trigger routing
	local routed = false
	local route_result = nil
	if point_type == "A" then
		routed, route_result = route_action_to_comsrv(instance_id, point_id, value)
	end

	-- Then write to modsrv Hash (state storage)
	local hash_key = string.format("modsrv:%s:%s", instance_id, point_type)
	redis.call("HSET", hash_key, point_id, value)

	return cjson.encode({
		status = "success",
		instance = instance_id,
		point_type = point_type,
		point_id = point_id,
		value = value,
		routed = routed,
		route_result = route_result,
	})
end

-- Batch execute modsrv actions
-- @function rulesrv_batch_execute
-- @input args[1]: actions_json - Array of action definitions
-- @output Array of execution results
-- @redis-write Multiple modsrv and comsrv keys
-- @side-effects Multiple action executions with routing
local function rulesrv_batch_execute(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: commands_json")
	end

	local commands, err = safe_decode(args[1], "commands")
	if err then
		return err
	end

	local results = {}
	for _, cmd in ipairs(commands) do
		if cmd.type == "modsrv_command" then
			local result = rulesrv_execute_modsrv_action({}, {
				cmd.instance_id,
				cmd.point_type,
				cmd.point_id,
				cmd.value,
			})
			table.insert(results, result)
		end
	end

	return cjson.encode({
		status = "success",
		executed = #commands,
		results = results,
	})
end

-- ============ Rule Execution ============

-- Execute a complete rule
-- @function rulesrv_execute_rule
-- @input args[1]: rule_id - Rule identifier
-- @output Execution result with conditions and actions status
-- @redis-read rulesrv:rules:{id} - Rule definition
-- @redis-write Via action execution to modsrv/comsrv
-- @side-effects Evaluates conditions and executes actions
local function rulesrv_execute_rule(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: rule_json")
	end

	local rule, err = safe_decode(args[1], "rule")
	if err then
		return err
	end

	-- Check if rule is enabled
	if rule.enabled == false then
		return cjson.encode({
			status = "skipped",
			reason = "Rule is disabled",
		})
	end

	-- Evaluate conditions
	local conditions_met = true

	if rule.conditions then
		-- Simple condition evaluation (can be extended for complex logic)
		for _, condition in ipairs(rule.conditions) do
			local result = rulesrv_evaluate_condition({}, {
				condition.field,
				condition.operator,
				tostring(condition.value),
			})

			if result == "false" then
				conditions_met = false
				break
			end
		end
	end

	-- Execute actions if conditions are met
	local action_results = {}

	if conditions_met and rule.actions then
		for _, action in ipairs(rule.actions) do
			if action.type == "modsrv_command" then
				local result = rulesrv_execute_modsrv_action({}, {
					action.instance_id,
					action.point_type,
					tostring(action.point_id),
					tostring(action.value),
				})
				table.insert(action_results, result)
			elseif action.type == "set_value" then
				-- Restrict to modsrv instance points only (do not allow arbitrary keys)
				local instance_id, point_type, point_id = string.match(action.target, "^modsrv:([^:]+):([MA]):(%d+)$")
				if not (instance_id and point_type and point_id) then
					return cjson.encode({
						status = "error",
						error = "set_value target must be modsrv:{instance}:{M|A}:{point_id}",
					})
				end

				-- For action points, first trigger routing
				local routed = false
				local route_result = nil
				if point_type == "A" then
					routed, route_result = route_action_to_comsrv(instance_id, point_id, tostring(action.value))
				end

				-- Then write to modsrv Hash (state storage)
				local hash_key = string.format("modsrv:%s:%s", instance_id, point_type)
				redis.call("HSET", hash_key, point_id, tostring(action.value))

				table.insert(
					action_results,
					cjson.encode({
						status = "success",
						type = "set_value",
						target = action.target,
						routed = routed,
						route_result = route_result,
					})
				)
			end
		end
	end

	return cjson.encode({
		status = conditions_met and "executed" or "skipped",
		rule_id = rule.id,
		conditions_met = conditions_met,
		actions_executed = #action_results,
		results = action_results,
	})
end

-- ============ Rule Management Functions ============

-- List all rules
-- @function rulesrv_list_rules
-- @output JSON array of all rule definitions
-- @redis-read rulesrv:rules:* - All rule configurations
-- @side-effects None (read-only operation)
local function rulesrv_list_rules(keys, args)
	local cursor = "0"
	local rules = {}

	repeat
		local res = redis.call("SCAN", cursor, "MATCH", "rulesrv:rule:*", "COUNT", 100)
		cursor = res[1]
		local rule_keys = res[2]

		for _, key in ipairs(rule_keys) do
			-- Skip metadata keys
			if not string.match(key, ":executions$") and not string.match(key, ":stats$") then
				local rule_json = redis.call("GET", key)
				if rule_json then
					local ok, rule = pcall(cjson.decode, rule_json)
					if ok then
						table.insert(rules, rule)
					end
				end
			end
		end
	until cursor == "0"

	return cjson.encode(rules)
end

-- Upsert (create or update) a rule
-- @function rulesrv_upsert_rule
-- @input args[1]: rule_id - Rule identifier
-- @input args[2]: rule_json - Rule definition
-- @output Success status
-- @redis-write rulesrv:rules:{id} - Store rule
-- @side-effects Rule immediately available for execution
local function rulesrv_upsert_rule(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: rule_json")
	end

	local rule, err = safe_decode(args[1], "rule")
	if err then
		return err
	end

	if not rule.id then
		return redis.error_reply("Rule must have an id")
	end

	-- Store rule
	local rule_key = string.format("rulesrv:rule:%s", rule.id)
	redis.call("SET", rule_key, cjson.encode(rule))

	-- Add to index
	redis.call("SADD", "rulesrv:rules", rule.id)

	-- Initialize stats if new rule
	local stats_key = string.format("rulesrv:rule:%s:stats", rule.id)
	if redis.call("EXISTS", stats_key) == 0 then
		redis.call("HSET", stats_key, "executions", "0", "successes", "0", "failures", "0")
	end

	return redis.status_reply("OK")
end

-- Get a specific rule
-- @function rulesrv_get_rule
-- @input args[1]: rule_id - Rule identifier
-- @output Rule definition JSON or null
-- @redis-read rulesrv:rules:{id} - Rule configuration
-- @side-effects None (read-only operation)
local function rulesrv_get_rule(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: rule_id")
	end

	local rule_id = args[1]
	local rule_key = string.format("rulesrv:rule:%s", rule_id)
	local rule_json = redis.call("GET", rule_key)

	if not rule_json then
		return cjson.encode({ error = "Rule not found" })
	end

	return rule_json
end

-- Delete a rule
-- @function rulesrv_delete_rule
-- @input args[1]: rule_id - Rule to delete
-- @output Deletion status
-- @redis-delete rulesrv:rules:{id} - Remove rule
-- @side-effects Rule no longer available for execution
local function rulesrv_delete_rule(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: rule_id")
	end

	local rule_id = args[1]
	local rule_key = string.format("rulesrv:rule:%s", rule_id)

	-- Delete rule and related data
	redis.call("DEL", rule_key)
	redis.call("DEL", string.format("rulesrv:rule:%s:stats", rule_id))
	redis.call("DEL", string.format("rulesrv:rule:%s:executions", rule_id))

	-- Remove from index
	redis.call("SREM", "rulesrv:rules", rule_id)

	return redis.status_reply("OK")
end

-- Enable a rule
-- @function rulesrv_enable_rule
-- @input args[1]: rule_id - Rule to enable
-- @output Enable status
-- @redis-write rulesrv:rules:{id} - Update enabled flag
-- @side-effects Rule starts being evaluated
local function rulesrv_enable_rule(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: rule_id")
	end

	local rule_id = args[1]
	local rule_key = string.format("rulesrv:rule:%s", rule_id)
	local rule_json = redis.call("GET", rule_key)

	if not rule_json then
		return redis.error_reply("Rule not found")
	end

	local ok, rule = pcall(cjson.decode, rule_json)
	if not ok then
		return redis.error_reply("Invalid rule data")
	end

	rule.enabled = true
	redis.call("SET", rule_key, cjson.encode(rule))

	return redis.status_reply("OK")
end

-- Disable a rule
-- @function rulesrv_disable_rule
-- @input args[1]: rule_id - Rule to disable
-- @output Disable status
-- @redis-write rulesrv:rules:{id} - Update enabled flag
-- @side-effects Rule stops being evaluated
local function rulesrv_disable_rule(keys, args)
	if #args < 1 then
		return redis.error_reply("Usage: rule_id")
	end

	local rule_id = args[1]
	local rule_key = string.format("rulesrv:rule:%s", rule_id)
	local rule_json = redis.call("GET", rule_key)

	if not rule_json then
		return redis.error_reply("Rule not found")
	end

	local ok, rule = pcall(cjson.decode, rule_json)
	if not ok then
		return redis.error_reply("Invalid rule data")
	end

	rule.enabled = false
	redis.call("SET", rule_key, cjson.encode(rule))

	return redis.status_reply("OK")
end

-- Statistics functions removed - not being maintained
-- These keys (rulesrv:rule:{id}:stats, rulesrv:rule:{id}:executions) are not written to

-- ============ Function Registration ============

redis.register_function("rulesrv_get_modsrv_data", rulesrv_get_modsrv_data)
redis.register_function("rulesrv_get_field_value", rulesrv_get_field_value)
redis.register_function("rulesrv_evaluate_condition", rulesrv_evaluate_condition)
redis.register_function("rulesrv_execute_modsrv_action", rulesrv_execute_modsrv_action)
redis.register_function("rulesrv_batch_execute", rulesrv_batch_execute)
redis.register_function("rulesrv_execute_rule", rulesrv_execute_rule)

-- Register rule management functions
redis.register_function("rulesrv_list_rules", rulesrv_list_rules)
redis.register_function("rulesrv_upsert_rule", rulesrv_upsert_rule)
redis.register_function("rulesrv_get_rule", rulesrv_get_rule)
redis.register_function("rulesrv_delete_rule", rulesrv_delete_rule)
redis.register_function("rulesrv_enable_rule", rulesrv_enable_rule)
redis.register_function("rulesrv_disable_rule", rulesrv_disable_rule)
-- Statistics function registrations removed
