--
-- (C) 2014-24 - ntop.org
--
local dirs = ntop.getDirs()
package.path = dirs.installdir .. "/scripts/lua/modules/pools/?.lua;" .. package.path
package.path = dirs.installdir .. "/scripts/lua/modules/alert_store/?.lua;" .. package.path

local clock_start = os.clock()

-- This file contains the description of all functions
-- used to trigger host alerts
local verbose = ntop.getCache("ntopng.prefs.alerts.debug") == "1"

local json = require("dkjson")
local recovery_utils = require "recovery_utils"
local alert_entities = require "alert_entities"
local alert_consts = require "alert_consts"
local format_utils = require "format_utils"
local alerts_api = require "alerts_api"
local icmp_utils = require "icmp_utils"
local flow_risk_utils = require "flow_risk_utils"
require "check_redis_prefs"

local shaper_utils = nil

if (ntop.isnEdge()) then
    package.path = dirs.installdir .. "/pro/scripts/lua/modules/?.lua;" .. package.path
    shaper_utils = require("shaper_utils")
end

-- ##############################################

local alert_utils = {}

-- ##############################################

-- @brief Return a callback for formatting the alert
-- @param alert_key The alert type
-- @param entity_id The alert entity
-- @return a format callback as defined in alert_definitions whose parameters are:
-- - ifid
-- - alert
-- - alert_json
-- - local_explorer: true if called by the local alert explorer, false if exported to an endpoint
local function alertTypeDescription(alert_key, entity_id)
    local alert_id = alert_consts.getAlertType(alert_key, entity_id)

    if alert_id and alert_consts.alert_types[alert_id].format then
        return alert_consts.alert_types[alert_id].format
    end

    return nil
end

-- ##############################################

local function get_make_room_keys(ifId)
    return {
        flows = "ntopng.cache.alerts.ifid_" .. ifId .. ".make_room_flow_alerts",
        entities = "ntopng.cache.alerts.ifid_" .. ifId .. ".make_room_closed_alerts"
    }
end

-- #################################

-- @brief Deletes all stored alerts matching an host and an IP
-- @return nil
function alert_utils.deleteFlowAlertsMatchingHost(host_ip, vlan_id, alert_id)
    local flow_alert_store = require("flow_alert_store").new()

    if not isEmptyString(host_ip) then
        flow_alert_store:add_ip_filter(hostkey2hostinfo(host_ip)["host"])
    end

    if (vlan_id) and (tonumber(vlan_id) ~= 0) then
        flow_alert_store:add_vlan_filter(vlan_id)
    end

    flow_alert_store:add_alert_id_filter(alert_id)

    -- Perform the actual deletion
    flow_alert_store:delete()
end

-- #################################

-- @brief Deletes all stored alerts matching a domain
-- @return nil
function alert_utils.deleteFlowAlertsMatchingDomain(domain_name, alert_id)
    local flow_alert_store = require("flow_alert_store").new()

    if not isEmptyString(domain_name) then
        flow_alert_store:add_domain_filter(domain_name)
    end

    if alert_id then
        flow_alert_store:add_alert_id_filter(alert_id)
    end

    -- Perform the actual deletion
    flow_alert_store:delete()
end

-- #################################

-- @brief Deletes all stored alerts matching a certificate
-- @return nil
function alert_utils.deleteFlowAlertsMatchingCertificate(certificate, alert_id)
    -- TODO
end

-- #################################

-- @brief Deletes all stored alerts matching an host and an IP
-- @return nil
function alert_utils.deleteHostAlertsMatchingHost(host_ip, vlan_id, alert_id)
    local host_alert_store = require("host_alert_store").new()

    if not isEmptyString(host_ip) then
        host_alert_store:add_ip_filter(hostkey2hostinfo(host_ip)["host"])
    end

    if (vlan_id) and (tonumber(vlan_id) ~= 0) then
        host_alert_store:add_vlan_filter(vlan_id)
    end

    host_alert_store:add_alert_id_filter(alert_id)

    -- Perform the actual deletion
    host_alert_store:delete()
end

-- #################################

-- this function returns an object with parameters specific for one tab
function alert_utils.getTabParameters(_get, what)
    local opts = {}
    for k, v in pairs(_get) do
        opts[k] = v
    end

    -- these options are contextual to the current tab (status)
    if _get.status ~= what then
        opts.alert_id = nil
    end
    if not isEmptyString(what) then
        opts.status = what
    end
    opts.ifid = interface.getId()
    return opts
end

-- #################################

-- Return more information for the flow alert description
local function getAlertTypeInfo(record, alert_info)
    local res = ""

    local l7proto_name = interface.getnDPIProtoName(tonumber(record["l7_proto"]) or 0)

    if l7proto_name == "ICMP" then -- is ICMPv4
        -- TODO: old format - remove when the all the flow alers will be generated in lua
        local type_code = {
            type = alert_info["icmp.icmp_type"],
            code = alert_info["icmp.icmp_code"]
        }

        if table.empty(type_code) and alert_info["icmp"] then
            -- This is the new format created when setting the alert from lua
            type_code = {
                type = alert_info["icmp"]["type"],
                code = alert_info["icmp"]["code"]
            }
        end

        if alert_info["icmp.unreach.src_ip"] then -- TODO: old format to be removed
            res = string.format("[%s]", i18n("icmp_page.icmp_port_unreachable_extra", {
                unreach_host = alert_info["icmp.unreach.dst_ip"],
                unreach_port = alert_info["icmp.unreach.dst_port"],
                unreach_protocol = l4_proto_to_string(alert_info["icmp.unreach.protocol"])
            }))
        elseif alert_info["icmp"] and alert_info["icmp"]["unreach"] then -- New format
            res = string.format("[%s]", i18n("icmp_page.icmp_port_unreachable_extra", {
                unreach_host = alert_info["icmp"]["unreach"]["dst_ip"],
                unreach_port = alert_info["icmp"]["unreach"]["dst_port"],
                unreach_protocol = l4_proto_to_string(alert_info["icmp"]["unreach"]["protocol"])
            }))
        else
            res = string.format("[%s]",
                icmp_utils.get_icmp_label(4 --[[ ipv4 --]], type_code["type"], type_code["code"]))
        end
    end

    return string.format(" %s", res)
end

-- #################################

-- This function formats flows in alerts
function alert_utils.formatRawFlow(alert, nohtml)
    require "flow_utils"
    local time_bounds
    local add_links = false

    -- pretend alert is a flow to reuse getFlowLabel
    local flow = {
        ["cli.ip"] = alert["cli_ip"],
        ["cli.port"] = tonumber(alert["cli_port"]),
        ["cli.blacklisted"] = tostring(alert["cli_blacklisted"]) == "1",
        ["cli.localhost"] = tostring(alert["cli_localhost"]) == "1",
        ["cli.host"] = alert["cli_name"],
        ["srv.ip"] = alert["srv_ip"],
        ["srv.port"] = tonumber(alert["srv_port"]),
        ["srv.blacklisted"] = tostring(alert["srv_blacklisted"]) == "1",
        ["srv.localhost"] = tostring(alert["srv_localhost"]) == "1",
        ["srv.host"] = alert["srv_name"],
        ["vlan"] = alert["vlan_id"]
    }

    flow = "<i class=\"fas fa-stream\"></i> " .. (getFlowLabel(flow, false, add_links, time_bounds, {
        page = "alerts"
    }, nil, true, nohtml) or "")

    return flow
end

-- #################################

function alert_utils.disableAlertsGeneration()
    if not isAdministratorOrPrintErr() then
        return
    end

    -- Ensure we do not conflict with others
    ntop.setPref("ntopng.prefs.disable_alerts_generation", "1")
    if (verbose) then
        io.write("[Alerts] Disable done\n")
    end
end

-- #################################

local function alertNotificationActionToLabel(action, use_emoji)
    local label = ""

    if action == "engage" then
        label = "["
        if (use_emoji) then
            label = label .. "\xE2\x9D\x97 "
        end
        label = label .. "Engaged"
        label = label .. "]"
    elseif action == "release" then
        label = "["
        if (use_emoji) then
            label = label .. "\xE2\x9C\x94 "
        end
        label = label .. "Released"
        label = label .. "]"
    end

    return label
end

-- #################################

function alert_utils.getConfigsetURL(script_key, subdir)
    return string.format('%s/lua/admin/edit_configset.lua?subdir=%s&check=%s#all', ntop.getHttpPrefix(), subdir,
        script_key)
end

-- #################################

function alert_utils.getConfigsetAlertLink(alert_json, alert --[[ optional --]], alert_entity)
    if isAdministrator() then
        local info = alert_json.alert_generation or (alert_json.alert_info and alert_json.alert_info.alert_generation)
        if alert_entity and alert_entity == alert_entities.am_host.entity_id then
            local host = alert_json.host.host or alert_json.host
            if host then
                local measurement = alert_json.host.measurement or alert_json.measurement
                if measurement then
                    return
                        ' <a href="' .. ntop.getHttpPrefix() .. '/lua/active_monitoring.lua?host=' ..
                        host .. '&measurement=' .. measurement .. '&page=overview"><i class="fas fa-cog" title="' ..
                        i18n("edit_configuration") .. '"></i></a>'
                else
                    return
                        ' <a href="' .. ntop.getHttpPrefix() .. '/lua/active_monitoring.lua?host=' ..
                        host .. '&page=overview"><i class="fas fa-cog" title="' .. i18n("edit_configuration") ..
                        '"></i></a>'
                end
            else
                return ' <a href="' .. ntop.getHttpPrefix() ..
                    '/lua/active_monitoring.lua?page=overview"><i class="fas fa-cog" title="' ..
                    i18n("edit_configuration") .. '"></i></a>'
            end
        elseif info then
            return (' <a href="' .. alert_utils.getConfigsetURL(info.script_key, info.subdir) .. '">' ..
                '<i class="fas fa-cog" title="' .. i18n("edit_configuration") .. '"></i></a>')
        else
            return (' <a href="' .. ntop.getHttpPrefix() .. '/lua/admin/edit_configset.lua?subdir=interface#all">' ..
                '<i class="fas fa-cog" title="' .. i18n("edit_configuration") .. '"></i></a>')
        end
    end

    return ('')
end

-- #################################

function alert_utils.getAlertInfo(alert)
    local alert_json = alert["json"] or alert["alert_json"]

    if isEmptyString(alert_json) then
        alert_json = {}
    elseif (string.sub(alert_json, 1, 1) == "{") then
        alert_json = json.decode(alert_json) or {}
    end

    return alert_json
end

-- #################################

function alert_utils.formatAlertMessage(ifid, alert, alert_json, local_explorer)
    local msg

    if (alert_json == nil) then
        alert_json = alert_utils.getAlertInfo(alert)
    end

    local description = alertTypeDescription(alert.alert_id, alert.entity_id)

    msg = alert_json
    if (type(description) == "string") then
        -- localization string
        msg = i18n(description, msg)
    elseif (type(description) == "function") then
        msg = description(ifid, alert, alert_json, local_explorer)
    end

    if (type(msg) == "table") then
        return ("")
    end

    if isEmptyString(msg) then
        msg = alert_consts.alertTypeLabel(tonumber(alert.alert_id), true --[[ no_html --]], alert.entity_id)
    end

    if not isEmptyString(alert["user_label"]) then
        msg = string.format('%s <small><span class="text-muted">%s</span></small>', msg, alert["user_label"])
    end

    return (msg or "")
end

-- #################################

-- Return a risk info (raw text, do not return a formatted value)
function alert_utils.get_flow_risk_info(alert_risk, alert_json)
    if alert_json and alert_json.flow_risk_info then
        if alert_json.flow_risk_info[tostring(alert_risk)] then
            return alert_json.flow_risk_info[tostring(alert_risk)]
        end
    end

    return ''
end

-- #################################

function alert_utils.format_score(msg, alert_score)
    if (alert_score) and (tonumber(alert_score)) and (tonumber(alert_score) > 0) then
        msg = string.format("%s [Score: %d] ", msg, tonumber(alert_score))
    end

    return msg
end

-- #################################

function alert_utils.formatAlertFromFlow(alert)
    local alert_tmp = table.clone(alert)
    alert_tmp.vlan_id = alert.vlan
    alert_tmp.packets = alert.packets
    alert_tmp.total_bytes = alert.bytes
    alert_tmp.cli2srv_pkts = alert["cli2srv.packets"]
    alert_tmp.srv2cli_pkts = alert["srv2cli.packets"]
    alert_tmp.cli2srv_bytes = alert["cli2srv.bytes"]
    alert_tmp.srv2cli_bytes = alert["srv2cli.bytes"]

    return alert_tmp
end

-- #################################

function alert_utils.formatFlowAlertMessage(ifid, alert, alert_json, add_score, local_explorer, exclude_remediation_link)
    local msg
    local alert_risk

    if tonumber(alert.alert_id) then
        alert_risk = ntop.getFlowAlertRisk(tonumber(alert.alert_id))

        if (tonumber(alert_risk) == 0) then
            alert_src = "ntopng"
            alert_risk = tonumber(alert.alert_id)
        else
            alert_src = "nDPI"
        end
    end

    if not alert_json then
        alert_json = alert_utils.getAlertInfo(alert)
    end

    local description = alertTypeDescription(alert.alert_id, alert_entities.flow.entity_id)

    if (type(description) == "string") then
        -- localization string
        msg = i18n(description, alert_json)
    elseif (type(description) == "function") then
        msg = description(ifid, alert, alert_json, local_explorer)
    end

    if isEmptyString(msg) then
        if alert_json and alert_json.alert_generation and alert_risk and alert_risk > 0 then
            -- Flow risks most of the times already have a default description, use this in case of emtpy descr
            msg = alert_utils.get_flow_risk_info(alert_risk, alert_json)
        else
            -- Normal alerts
            msg = alert_consts.alertTypeLabel(tonumber(alert.alert_id), true --[[ no_html --]], alert.entity_id)
        end
    end

    if not isEmptyString(alert["user_label"]) then
        msg = string.format('%s <small><span class="text-muted">%s</span></small>', msg, alert["user_label"])
    end

    if add_score then
        if tonumber(alert.alert_id) then
            local alert_score = ntop.getFlowAlertScore(tonumber(alert.alert_id))
            msg = alert_utils.format_score(msg, alert_score)
        end
    end

    -- Add the link to the documentation
    if alert_risk and alert_risk > 0 and not exclude_remediation_link then
        if isEmptyString(msg) and not isEmptyString(alert.info) then
            msg = alert.info
        end
        msg = string.format("%s %s",
            msg, flow_risk_utils.get_remediation_documentation_link(alert_risk, alert_src))
        local info_msg = alert_utils.get_flow_risk_info(alert_risk, alert_json)
    end

    return msg or ""
end

-- #################################

function alert_utils.getLinkToPastFlows(ifid, alert, alert_json)
    if not ntop.isEnterpriseM() or not hasClickHouseSupport() then
        -- nIndex not enabled or enabled but not available for this particular interface
        return
    end

    local epoch_begin = alert["tstamp"]
    local epoch_end = alert["tstamp_end"]

    -- Fetch the alert id
    local alert_id = alert_consts.getAlertType(alert.alert_id, alert.entity_id)
    if alert_id and epoch_begin and epoch_end then
        local final_filter = {}
        local filters = {}

        -- Look a bit around the epochs
        epoch_begin = epoch_begin - (5 * 60)
        epoch_end = epoch_end + (5 * 60)

        -- IP
        if not isEmptyString(alert["ip"]) then
            -- Check if it's an attacker or a victim
            if not isEmptyString(alert["is_attacker"]) and alert["is_attacker"] ~= 0 then
                filters[#filters + 1] = {
                    name = "cli_ip",
                    op = "eq",
                    val = alert["ip"]
                }
            elseif not isEmptyString(alert["is_victim"]) and alert["is_victim"] ~= 0 then
                filters[#filters + 1] = {
                    name = "srv_ip",
                    op = "eq",
                    val = alert["ip"]
                }
            else
                filters[#filters + 1] = {
                    name = "ip",
                    op = "eq",
                    val = alert["ip"]
                }
            end

            -- Add the hostname here cause it's needed to check if the ip is equal to the name
            -- Hostname
            if not isEmptyString(alert["name"]) and (alert["ip"] ~= alert["name"]) then
                filters[#filters + 1] = {
                    name = "name",
                    op = "eq",
                    val = alert["name"]
                }
            end
        end

        -- VLAN ID
        if not isEmptyString(alert["vlan_id"]) and tonumber(alert["vlan_id"]) > 0 then
            filters[#filters + 1] = {
                name = "vlan_id",
                op = "eq",
                val = alert["vlan_id"]
            }
        end

        -- Host alerts could have a custom function to format the url, in case call it
        -- and then merge the filters
        if alert_consts.alert_types[alert_id].filter_to_past_flows then
            local past_flows_filter = alert_consts.alert_types[alert_id].filter_to_past_flows(ifid, alert, alert_json)
            table.merge(filters, past_flows_filter)
        end

        for _, tag in pairs(filters) do
            final_filter[tag.name] = string.format("%s%s%s", tag.val, alert_consts.SEPARATOR, tag.op)
        end

        -- Return the link augmented with the filter
        local res = string.format("%s/lua/pro/db_search.lua?epoch_begin=%u&epoch_end=%u&%s", ntop.getHttpPrefix(),
            epoch_begin, epoch_end, table.tconcat(final_filter, "=", "&"))

        return res
    end

    return nil
end

-- #################################

function alert_utils.notification_timestamp_rev(a, b)
    return (a.tstamp > b.tstamp)
end

function alert_utils.severity_rev(a, b)
    return (a.severity_id > b.severity_id)
end

-- #################################
--
-- Returns a summary of the alert as readable text
function alert_utils.formatAlertNotification(notif, options)
    -- Otherwise format the alert
    local defaults = {
        show_severity = true,
        nohtml = false,
        nolabelhtml = false,
        emoji = false
    }
    options = table.merge(defaults, options)

    local ifname
    local severity
    local when

    if (notif.ifid ~= -1) then
        ifname = string.format(" [Interface: %s]", getInterfaceName(notif.ifid))
    else
        ifname = ""
    end

    if (options.show_severity == false) then
        severity = ""
    else
        severity = " [Severity: " ..
            alert_consts.alertSeverityLabel(notif.score, options.nohtml or options.nolabelhtml, options.emoji) ..
            "]"
    end

    if (options.nodate == true) then
        when = ""
    else
        if options.timezone then
            when = format_utils.formatEpochISO8601(notif.tstamp_end or notif.tstamp or 0)
        else
            when = format_utils.formatEpoch(notif.tstamp_end or notif.tstamp or 0)
        end

        if (not options.no_bracket_around_date) then
            when = " [" .. when .. "]"
        end

        when = when .. " "
    end

    local msg = string.format("%s%s%s", when, ifname, severity)

    local entity_label = ""
    if notif.entity_id then
        entity_label = alert_consts.alertEntityLabel(notif.entity_id) or ""
    end

    -- entity can be hidden for example when one is OK with just the message
    if options.show_entity and not isEmptyString(entity_label) then
        msg = msg .. " [" .. entity_label .. "]"
    end

    local alert_type_label = alert_consts.alertTypeLabel(notif.alert_id, options.nohtml or options.nolabelhtml,
        notif.entity_id, true)
    if alert_type_label then
        msg = msg .. " [" .. alert_type_label .. "]"
    end

    -- entity can be hidden for example when one is OK with just the message
    if notif.entity_id and notif.entity_val then
        local ev = notif.entity_val

        if notif.entity_id == alert_entities.flow.entity_id then
            ev = noHtml(alert_utils.formatRawFlow(notif, options.nohtml))
        elseif notif.entity_id == alert_entities.host.entity_id then
            -- suppress @0 when the vlan is zero
            ev = hostinfo2hostkey(hostkey2hostinfo(notif.entity_val))
        elseif notif.entity_id == alert_entities.am_host.entity_id then
            -- show host only, hiding measurement id (e.g. vs@)
            local parts = split(notif.entity_val, "@")
            if #parts == 2 then
                ev = parts[2]
            end
        end
        msg = msg .. " [" .. (ev or '') .. "]"
    end

    -- add the label, that is, engaged or released
    msg = msg .. " " .. alertNotificationActionToLabel(notif.action, options.emoji) .. " "

    local alert_message = alert_utils.formatAlertMessage(notif.ifid, notif)

    if (options.add_cr) then
        msg = msg .. "\n"
    end

    if options.nohtml then
        msg = msg .. noHtml(alert_message)
    else
        msg = msg .. alert_message
    end

    local alert_title = ""
    if not isEmptyString(entity_label) then
        alert_title = string.format("[%s]: ", entity_label)
    end
    alert_title = alert_title .. alert_consts.alertTypeLabel(notif.alert_id, options.nohtml, notif.entity_id)

    return msg, alert_title, entity_label
end

-- ##############################################

function alert_utils.formatAlertCulprit(notif)
    local msg

    -- Formatting cli-srv
    if not isEmptyString(notif.cli_ip) and not isEmptyString(notif.srv_ip) then
        local client = notif.cli_ip
        local server = notif.srv_ip
        local client_port = ''
        local server_port = ''

        if not isEmptyString(notif.cli_name) then
            client = notif.cli_name
        end

        if not isEmptyString(notif.srv_name) then
            server = notif.srv_name
        end

        if notif.cli_port ~= 0 then
            client_port = ':' .. notif.cli_port
        end

        if notif.srv_port ~= 0 then
            server_port = ':' .. notif.srv_port
        end

        msg = string.format('%s%s \xE2\x9E\xA1 %s%s', client, client_port, server, server_port)
    elseif not isEmptyString(notif.entity_val) then
        msg = notif.entity_val

        if notif.name and not isEmptyString(notif.name) then
            msg = notif.name
        end
    else
        msg = ''
    end

    return msg
end

-- ##############################################

local function notify_ntopng_status(started)
    local info = ntop.getInfo()
    local score = 10
    local msg
    local msg_details = string.format("%s v.%s (%s) [OS: %s][pid: %s][options: %s]", info.product, info.version,
        info.revision, info.OS, info.pid, info.command_line)
    local anomalous = false
    local event

    if (started) then
        -- reading current version and last version to check if it has been updated
        local last_version_key = "ntopng.updates.last_version"
        local last_version = ntop.getCache(last_version_key)
        local curr_version = info["version"] .. "-" .. info["revision"]
        ntop.setCache(last_version_key, curr_version)

        -- let's check if we are restarting from an anomalous termination
        -- e.g., from a crash
        if not recovery_utils.check_clean_shutdown() then
            -- anomalous termination
            msg = string.format("%s %s", i18n("alert_messages.ntopng_anomalous_termination", {
                url = "https://www.ntop.org/support/need-help-2/need-help/"
            }), msg_details)
            score = 100
            anomalous = true
            event = "anomalous_termination"
        elseif not isEmptyString(last_version) and last_version ~= curr_version then
            -- software update
            msg = string.format("%s %s", i18n("alert_messages.ntopng_update"), msg_details)
            event = "update"
        else
            -- normal termination
            msg = string.format("%s %s", i18n("alert_messages.ntopng_start"), msg_details)
            event = "start"
        end
    else
        msg = string.format("%s %s", i18n("alert_messages.ntopng_stop"), msg_details)
        event = "stop"
    end

    local entity_value = ntop.getInfo().product
    local entity_info = alerts_api.systemEntity(entity_value)
    local type_info = alert_consts.alert_types.alert_process_notification.new(event, msg_details)

    type_info:set_score(score)

    return (type_info:store(entity_info))
end

function alert_utils.notify_ntopng_start()
    return (notify_ntopng_status(true))
end

function alert_utils.notify_ntopng_stop()
    return (notify_ntopng_status(false))
end

-- ##############################################

local function addDayEpoch(res, day_epoch)
    res[day_epoch] =
    { 0, --[[ Counter for alerts between 00:00 and 00:59 UTC --]] 0, --[[ Counter for alerts between 01:00 and 01:59 UTC --]]
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 --[[ Counter for alerts in other hours, until 23:00 to 23:59 --]] }

    return res
end

-- ##############################################

-- @brief Function used to format the old timeseries format (CheckMK integration)
function alert_utils.formatOldTimeseries(q_res, _epoch_begin, _epoch_end)
    local hour_secs = 60 * 60
    local day_secs = 60 * 60 * 24
    local epoch_begin = _epoch_begin - (_epoch_begin % day_secs)      -- Round begin to start of day
    local epoch_end = _epoch_end - (_epoch_end % day_secs) + day_secs -- Round end to end of day
    local res = {}

    for _, v in ipairs(q_res) do
        local tstamp = v.hour or v.tstamp
        -- Midnight UTC of the day containing v.hour
        local day_epoch = tstamp - (tstamp % day_secs)

        -- Hour of the day containing v.hour, from 0 to 23, inclusive
        -- NOTE: Use the `floor` to make sure hour is an integer as it will be used to index a Lua array
        local hour = math.floor((tstamp - day_epoch) / hour_secs)

        -- Here we add 1 to the hour as Lua array are indexed starting from 1, whereas `hour` is an integer starting from zero
        if not res[day_epoch] then
            res = addDayEpoch(res, day_epoch)
        end

        -- This is done for both, enganged and historical alert, historical have v.hour and v.count, instead engaged
        -- There is one entry per engaged, reporting the starting time (tstamp), so just add +1 to the hour having that alert
        res[day_epoch][hour + 1] = tonumber( --[[ Historical ]]
            v.count or --[[ Engaged ]] ((res[day_epoch][hour + 1] or 0) + 1))
    end -- for

    return res
end

-- ##############################################

function alert_utils.format_other_alerts(alert_bitmap, predominant_alert, alert_json, add_score, no_html, json_format)
    local other_alerts_by_score = {} -- Table used to keep messages ordered by score
    local additional_alerts = {}
    -- This is for old data, so keep it like this
    if not (alert_json) or (aler_json and not alert_json.alerts) then
        -- Unpack all flow alerts, iterating the alerts_map. The alerts_map is stored as an HEX.
        local nibble_num = 0 -- Current nibble being processed
        for alerts_map_nibble_id = #alert_bitmap, 1, -1 do
            -- Extract the nibble
            local alerts_map_hex_nibble = alert_bitmap:sub(alerts_map_nibble_id, alerts_map_nibble_id)
            -- Convert the HEX nibble into a decimal value
            local alerts_map_nibble = tonumber(alerts_map_hex_nibble, 16)

            if alerts_map_nibble > 0 then
                for bit_num = 0, 7 do
                    -- Checks the bits set in this current nibble
                    local has_bit = alerts_map_nibble & (1 << bit_num) == (1 << bit_num)

                    if has_bit then -- The bit is set
                        -- The actual alert id is the bit number times the current byte multiplied by 8
                        local alert_id = math.floor(8 * nibble_num / 2) + bit_num

                        if alert_id ~= tonumber(predominant_alert) then -- Do not add the predominant alert to the list of additional alerts
                            local message = alert_consts.alertTypeLabel(alert_id, true, alert_entities.flow.entity_id)
                            message = message ..
                            " " .. alert_consts.addExtraInfo(alert_id, alert_entities.flow.entity_id, alert_json)
                            local alert_score = ntop.getFlowAlertScore(alert_id)

                            if add_score then
                                message = alert_utils.format_score(message, alert_score)
                            end

                            local alert_risk = ntop.getFlowAlertRisk(tonumber(alert_id))

                            if alert_risk > 0 then -- source is nDPI
                                if not no_html then
                                    message = string.format("%s %s", message,
                                        flow_risk_utils.get_remediation_documentation_link(alert_risk, "nDPI"))
                                end
                                local info_msg = alert_utils.get_flow_risk_info(alert_risk, alert_json)
                                if not isEmptyString(info_msg) then
                                    message = string.format("%s [%s]", message, info_msg)
                                end
                            end

                            if not other_alerts_by_score[alert_score] then
                                other_alerts_by_score[alert_score] = {}
                            end

                            other_alerts_by_score[alert_score][#other_alerts_by_score[alert_score] + 1] = message
                            if json_format then
                                additional_alerts[#additional_alerts + 1] = {
                                    msg = message,
                                    score = alert_score,
                                    alert_id = alert_id
                                }
                            else
                                additional_alerts[#additional_alerts + 1] = message
                            end
                        end
                    end
                end
            end

            -- Increment the nibble
            nibble_num = nibble_num + 1
        end
    else
        -- This is for new code, where all the alerts can be found
        -- in the alert_json
        for alert_id, alert in pairs(alert_json.alerts or {}) do
            local message = alert_consts.alertTypeLabel(alert_id, true, alert_entities.flow.entity_id)
            message = message .. " " .. alert_consts.addExtraInfo(alert_id, alert_entities.flow.entity_id, alert_json)
            if json_format then
                additional_alerts[#additional_alerts + 1] = {
                    msg = message,
                    score = alert.score,
                    alert_id = alert_id
                }
            else
                additional_alerts[#additional_alerts + 1] = message
            end
        end
    end

    return other_alerts_by_score, additional_alerts
end

-- ##############################################

function alert_utils.filter_notification(notification, recipient_id)
    local alert_info = json.decode(notification.alert)
    local alert_key = alert_info.alert_id
    local entity_id = alert_info.entity_id
    local entity_val = alert_info.entity_val

    if notification.score == 0 then
        return true
    end

    local alert_id = alert_consts.getAlertType(alert_key, entity_id)

    return alert_utils.check_alert_policy(entity_id, entity_val, alert_id, alert_info, recipient_id)
end

-- ##############################################

-- Function to filter duplicate alerts 
function alert_utils.check_alert_policy(entity_id, entity_val, alert_id, alert_info, recipient_id)
    local alert_key = ""
    local alert_key_fields = {}
    local not_set = true
    local silence_alerts = ntop.getCache("ntopng.prefs.silence_multiple_alerts." .. recipient_id) or "1"

    -- In case the alerts have to be silenced for the endpoint then silence them, otherwise skip
    if silence_alerts == "1" then
        if alert_consts.alert_types[alert_id].alert_retention_policy_key then
            alert_key_fields = alert_consts.alert_types[alert_id].alert_retention_policy_key(alert_info)

            for _, field in ipairs(alert_key_fields) do
                alert_key = alert_key .. "." .. alert_info[field]
            end
        else
            if not alert_entities[entity_val] then
                entity_val = alert_consts.alertEntityRaw(entity_id)
            end
            alert_key_fields = alert_entities[entity_val].alert_key_fields
            if (alert_key_fields) then
                for _, field in ipairs(alert_key_fields) do
                    alert_key = alert_key .. "." .. alert_info[field]
                end
            else
                return not_set
            end
        end

        if isEmptyString(alert_key) then
            return not_set
        end

        -- Check if the key is set in redis for this recipient_id, entity_id and alert_id
        local redis_key = string.format("ntopng.cache.alert.retention.%s.%s.%s%s", recipient_id, entity_id, alert_id, alert_key)
        local redis_value = ntop.getCache(redis_key)

        not_set = isEmptyString(redis_value)
        
        if not_set then
            -- Set key with expiration on redis to filter out the same alert for some time
            -- TODO: 3600 must be update with a user preference
            -- 3600 is in seconds, do not send duplicate alerts if they appear more than once in an hour
            ntop.setCache(redis_key, "1", 3600)
        end
    end

    return not_set -- true to send the alert, false to suppress this alert until the key expires
end

-- ##############################################

if (trace_script_duration ~= nil) then
    io.write(debug.getinfo(1, 'S').source .. " executed in " .. (os.clock() - clock_start) * 1000 .. " ms\n")
end

return alert_utils
