--- This is a port of Jicofo's Reservation System as a prosody module
--  ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
--
--  We try to retain the same behaviour and interfaces where possible, but there
--  is some difference:
--    * In the event that the DELETE call fails, Jicofo's reservation
--      system retains reservation data and allows re-creation of room if requested by
--      the same creator without making further call to the API; this module does not
--      offer this behaviour. Re-creation of a closed room will behave like a new meeting
--      and trigger a new API call to validate the reservation.
--    * Jicofo's reservation system expect int-based conflict_id. We take any sensible string.
--
--  In broad strokes, this module works by intercepting Conference IQs sent to focus component
--  and buffers it until reservation is confirmed (by calling the provided API endpoint).
--  The IQ events are routed on to focus component if reservation is valid, or error
--  response is sent back to the origin if reservation is denied. Events are routed as usual
--  if the room already exists.
--
--
--  Installation:
--  =============
--
--  Under domain config,
--   1. add "reservations" to modules_enabled.
--   2. Specify URL base for your API endpoint using "reservations_api_prefix" (required)
--   3. Optional config:
--      * set "reservations_api_timeout" to change API call timeouts (defaults to 20 seconds)
--      * set "reservations_api_headers" to specify custom HTTP headers included in
--        all API calls e.g. to provide auth tokens.
--      * set "reservations_api_retry_count" to the number of times API call failures are retried (defaults to 3)
--      * set "reservations_api_retry_delay" seconds to wait between retries (defaults to 3s)
--      * set "reservations_api_should_retry_for_code" to a function that takes an HTTP response code and
--        returns true if API call should be retried. By default, retries are done for 5XX
--        responses. Timeouts are never retried, and HTTP call failures are always retried.
--      * set "reservations_enable_max_occupants" to true to enable integration with
--        mod_muc_max_occupants. Setting thia will allow optional "max_occupants" (integer)
--        payload from API to influence max occupants allowed for a given room.
--      * set "reservations_enable_lobby_support" to true to enable integration
--        with "muc_lobby_rooms". Setting this will allow optional "lobby" (boolean)
--        fields in API payload. If set to true, Lobby will be enabled for the room.
--        "persistent_lobby" module must also be enabled for this to work.
--      * set "reservations_enable_password_support" to allow optional "password" (string)
--        field in API payload. If set and not empty, then room password will be set
--        to the given string.
--      * By default, reservation checks are skipped for breakout rooms. You can subject
--        breakout rooms to the same checks by setting "reservations_skip_breakout_rooms" to false.
--
--
--  Example config:
--
--    VirtualHost "jitmeet.example.com"
--        modules_enabled = {
--            "reservations";
--        }
--        reservations_api_prefix = "http://reservation.example.com"
--
--        --- The following are all optional
--        reservations_api_headers = {
--            ["Authorization"] = "Bearer TOKEN-237958623045";
--        }
--        reservations_api_timeout = 10  -- timeout if API does not respond within 10s
--        reservations_api_retry_count = 5  -- retry up to 5 times
--        reservations_api_retry_delay = 1  -- wait 1s between retries
--        reservations_api_should_retry_for_code = function (code)
--            return code >= 500 or code == 408
--        end
--
--        reservations_enable_max_occupants = true  -- support "max_occupants" field
--        reservations_enable_lobby_support = true  -- support "lobby" field
--        reservations_enable_password_support = true  -- support "password" field
--

local jid = require 'util.jid';
local http = require "net.http";
local json = require 'cjson.safe';
local st = require "util.stanza";
local timer = require 'util.timer';
local datetime = require 'util.datetime';

local util = module:require "util";
local get_room_from_jid = util.get_room_from_jid;
local is_healthcheck_room = util.is_healthcheck_room;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local process_host_module = util.process_host_module;

local api_prefix = module:get_option("reservations_api_prefix");
local api_headers = module:get_option("reservations_api_headers");
local api_timeout = module:get_option("reservations_api_timeout", 20);
local api_retry_count = tonumber(module:get_option("reservations_api_retry_count", 3));
local api_retry_delay = tonumber(module:get_option("reservations_api_retry_delay", 3));
local max_occupants_enabled = module:get_option("reservations_enable_max_occupants", false);
local lobby_support_enabled = module:get_option("reservations_enable_lobby_support", false);
local password_support_enabled = module:get_option("reservations_enable_password_support", false);
local skip_breakout_room = module:get_option("reservations_skip_breakout_rooms", true);


-- Option for user to control HTTP response codes that will result in a retry.
-- Defaults to returning true on any 5XX code or 0
local api_should_retry_for_code = module:get_option("reservations_api_should_retry_for_code", function (code)
   return code >= 500;
end)


local muc_component_host = module:get_option_string("main_muc");
local breakout_muc_component_host = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host);


-- How often to check and evict expired reservation data
local expiry_check_period = 60;


-- Cannot proceed if "reservations_api_prefix" not configured
if not api_prefix then
    module:log("error", "reservations_api_prefix not specified. Disabling %s", module:get_name());
    return;
end


-- get/infer focus component hostname so we can intercept IQ bound for it
local focus_component_host = module:get_option_string("focus_component");
if not focus_component_host then
    local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
    if not muc_domain_base then
        module:log("error", "Could not infer focus domain. Disabling %s", module:get_name());
        return;
    end
    focus_component_host = 'focus.'..muc_domain_base;
end

-- common HTTP headers added to all API calls
local http_headers = {
    ["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")";
};
if api_headers then -- extra headers from config
    for key, value in pairs(api_headers) do
       http_headers[key] = value;
    end
end


--- Utils

--- Converts int timestamp to datetime string compatible with Java SimpleDateFormat
-- @param t timestamps in seconds. Supports int (as returned by os.time()) or higher
--          precision (as returned by socket.gettime())
-- @return formatted datetime string (yyyy-MM-dd'T'HH:mm:ss.SSSX)
local function to_java_date_string(t)
    local t_secs, mantissa = math.modf(t);
    local ms_str = (mantissa == 0) and '.000' or tostring(mantissa):sub(2,5);
    local date_str = os.date("!%Y-%m-%dT%H:%M:%S", t_secs);
    return date_str..ms_str..'Z';
end


--- Start non-blocking HTTP call
-- @param url URL to call
-- @param options options table as expected by net.http where we provide optional headers, body or method.
-- @param callback if provided, called with callback(response_body, response_code) when call complete.
-- @param timeout_callback if provided, called without args when request times out.
-- @param retries how many times to retry on failure; 0 means no retries.
local function async_http_request(url, options, callback, timeout_callback, retries)
    local completed = false;
    local timed_out = false;
    local retries = retries or api_retry_count;

    local function cb_(response_body, response_code)
        if not timed_out then  -- request completed before timeout
            completed = true;
            if (response_code == 0 or api_should_retry_for_code(response_code)) and retries > 0 then
                module:log("warn", "API Response code %d. Will retry after %ds", response_code, api_retry_delay);
                timer.add_task(api_retry_delay, function()
                    async_http_request(url, options, callback, timeout_callback, retries - 1)
                end)
                return;
            end

            if callback then
                callback(response_body, response_code)
            end
        end
    end

    local request = http.request(url, options, cb_);

    timer.add_task(api_timeout, function ()
        timed_out = true;

        if not completed then
            http.destroy_request(request);
            if timeout_callback then
                timeout_callback()
            end
        end
    end);

end

--- Returns current timestamp
local function now()
    -- Don't really need higher precision of socket.gettime(). Besides, we loose
    -- milliseconds precision when converting back to timestamp from date string
    -- when we use datetime.parse(t), so let's be consistent.
    return os.time();
end

--- Start RoomReservation implementation

-- Status enums used in RoomReservation:meta.status
local STATUS = {
    PENDING = 0;
    SUCCESS = 1;
    FAILED  = -1;
}

local RoomReservation = {};
RoomReservation.__index = RoomReservation;

function newRoomReservation(room_jid, creator_jid)
    return setmetatable({
        room_jid = room_jid;

        -- Reservation metadata. store as table so we can set and read atomically.
        -- N.B. This should always be updated using self.set_status_*
        meta = {
            status = STATUS.PENDING;
            mail_owner = jid.bare(creator_jid);
            conflict_id = nil;
            start_time = now();  -- timestamp, in seconds
            expires_at = nil;  -- timestamp, in seconds
            error_text = nil;
            error_code = nil;
        };

        -- Array of pending events that we need to route once API call is complete
        pending_events = {};

        -- Set true when API call trigger has been triggered (by enqueue of first event)
        api_call_triggered = false;
    }, RoomReservation);
end


--- Extracts room name from room jid
function RoomReservation:get_room_name()
    return jid.node(self.room_jid);
end

--- Checks if reservation data is expires and should be evicted from store
function RoomReservation:is_expired()
    return self.meta.expires_at ~= nil and now() > self.meta.expires_at;
end

--- Main entry point for handing and routing events.
function RoomReservation:enqueue_or_route_event(event)
    if self.meta.status == STATUS.PENDING then
        table.insert(self.pending_events, event)
        if self.api_call_triggered ~= true then
            self:call_api_create_conference();
        end
    else
        -- API call already complete. Immediately route without enqueueing.
        -- This could happen if request comes in between the time reservation approved
        -- and when Jicofo actually creates the room.
        module:log("debug", "Reservation details already stored. Skipping queue for %s", self.room_jid);
        self:route_event(event);
    end
end

--- Updates status and initiates event routing. Called internally when API call complete.
function RoomReservation:set_status_success(start_time, duration, mail_owner, conflict_id, data)
    module:log("info", "Reservation created successfully for %s", self.room_jid);
    self.meta = {
        status = STATUS.SUCCESS;
        mail_owner = mail_owner or self.meta.mail_owner;
        conflict_id = conflict_id;
        start_time = start_time;
        expires_at = start_time + duration;
        error_text = nil;
        error_code = nil;
    }
    if max_occupants_enabled and data.max_occupants then
        self.meta.max_occupants = data.max_occupants
    end
    if lobby_support_enabled and data.lobby then
        self.meta.lobby = data.lobby
    end
    if password_support_enabled and data.password then
        self.meta.password = data.password
    end
    self:route_pending_events()
end

--- Updates status and initiates error response to pending events. Called internally when API call complete.
function RoomReservation:set_status_failed(error_code, error_text)
    module:log("info", "Reservation creation failed for %s - (%s) %s", self.room_jid, error_code, error_text);
    self.meta = {
        status = STATUS.FAILED;
        mail_owner = self.meta.mail_owner;
        conflict_id = nil;
        start_time = self.meta.start_time;
        -- Retain reservation rejection for a short while so we have time to report failure to
        -- existing clients and not trigger a re-query too soon.
        -- N.B. Expiry could take longer since eviction happens periodically.
        expires_at = now() + 30;
        error_text = error_text;
        error_code = error_code;
    }
    self:route_pending_events()
end

--- Triggers routing of all enqueued events
function RoomReservation:route_pending_events()
    if self.meta.status == STATUS.PENDING then  -- should never be called while PENDING. check just in case.
        return;
    end

    module:log("debug", "Routing all pending events for %s", self.room_jid);
    local event;

    while #self.pending_events ~= 0 do
        event = table.remove(self.pending_events);
        self:route_event(event)
    end
end

--- Event routing implementation
function RoomReservation:route_event(event)
    -- this should only be called after API call complete and status no longer PENDING
    assert(self.meta.status ~= STATUS.PENDING, "Attempting to route event while API call still PENDING")

    local meta = self.meta;
    local origin, stanza = event.origin, event.stanza;

    if meta.status == STATUS.FAILED then
        module:log("debug", "Route: Sending reservation error to %s", stanza.attr.from);
        self:reply_with_error(event, meta.error_code, meta.error_text);
    else
        if meta.status == STATUS.SUCCESS then
            if self:is_expired() then
                module:log("debug", "Route: Sending reservation expiry to %s", stanza.attr.from);
                self:reply_with_error(event, 419, "Reservation expired");
            else
                module:log("debug", "Route: Forwarding on event from %s", stanza.attr.from);
                prosody.core_post_stanza(origin, stanza, false); -- route iq to intended target (focus)
            end
        else
            -- this should never happen unless dev made a mistake. Block by default just in case.
            module:log("error", "Reservation for %s has invalid state %s. Rejecting request.", self.room_jid, meta.status);
            self:reply_with_error(event, 500, "Failed to determine reservation state");
        end
    end
end

--- Generates reservation-error stanza and sends to event origin.
function RoomReservation:reply_with_error(event, error_code, error_text)
    local stanza = event.stanza;
    local id = stanza.attr.id;
    local to = stanza.attr.from;
    local from = stanza.attr.to;

    event.origin.send(
        st.iq({ type="error", to=to, from=from, id=id })
            :tag("error", { type="cancel" })
                :tag("service-unavailable", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):up()
                :tag("text", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):text(error_text):up()
                :tag("reservation-error", { xmlns="http://jitsi.org/protocol/focus", ["error-code"]=tostring(error_code) })
    );
end

--- Initiates non-blocking API call to validate reservation
function RoomReservation:call_api_create_conference()
    self.api_call_triggered = true;

    local url = api_prefix..'/conference';
    local request_data = {
        name = self:get_room_name();
        start_time = to_java_date_string(self.meta.start_time);
        mail_owner = self.meta.mail_owner;
    }

    local http_options = {
        body = http.formencode(request_data);  -- because Jicofo reservation encodes as form data instead JSON
        method = 'POST';
        headers = http_headers;
    }

    module:log("debug", "Sending POST /conference for %s", self.room_jid);
    async_http_request(url, http_options, function (response_body, response_code)
        self:on_api_create_conference_complete(response_body, response_code);
    end, function ()
        self:on_api_call_timeout();
    end);
end

--- Parses and validates HTTP response body for conference payload
--  Ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
--  @return nil if invalid, or table with payload parsed from JSON response
function RoomReservation:parse_conference_response(response_body)
    local data, error = json.decode(response_body);

    if data == nil then  -- invalid JSON payload
        module:log("error", "Invalid JSON response from API - %s error:%s", response_body, error);
        return;
    end

    if data.name == nil or data.name:lower() ~= self:get_room_name() then
        module:log("error", "Missing or mismatching room name - %s", data.name);
        return;
    end

    if data.id == nil then
        module:log("error", "Missing id");
        return;
    end

    if data.mail_owner == nil then
        module:log("error", "Missing mail_owner");
        return;
    end

    local duration = tonumber(data.duration);
    if duration == nil then
        module:log("error", "Missing or invalid duration - %s", data.duration);
        return;
    end
    data.duration = duration;

    -- if optional "max_occupants" field set, cast to number
    if data.max_occupants ~= nil then
        local max_occupants = tonumber(data.max_occupants)
        if max_occupants == nil or max_occupants < 1 then
            -- N.B. invalid max_occupants rejected even if max_occupants_enabled=false
            module:log("error", "Invalid value for max_occupants - %s", data.max_occupants);
            return;
        end
        data.max_occupants = max_occupants
    end

    -- if optional "lobby" field set, accept boolean true or "true"
    if data.lobby ~= nil then
        if (type(data.lobby) == "boolean" and data.lobby) or data.lobby == "true" then
            data.lobby = true
        else
            data.lobby = false
        end
    end

    -- if optional "password" field set, it has to be string
    if data.password ~= nil then
        if type(data.password) ~= "string" then
            -- N.B. invalid "password" rejected even if reservations_enable_password_support=false
            module:log("error", "Invalid type for password - string expected");
            return;
        end
    end

    local start_time = datetime.parse(data.start_time);  -- N.B. we lose milliseconds portion of the date
    if start_time == nil then
        module:log("error", "Missing or invalid start_time - %s", data.start_time);
        return;
    end
    data.start_time = start_time;

    return data;
end

--- Parses and validates HTTP error response body for API call.
--  Expect JSON with a "message" field.
--  @return message string, or generic error message if invalid payload.
function RoomReservation:parse_error_message_from_response(response_body)
    local data = json.decode(response_body);
    if data ~= nil and data.message ~= nil then
        module:log("debug", "Invalid error response body. Will use generic error message.");
        return data.message;
    else
        return "Rejected by reservation server";
    end
end

--- callback on API timeout
function RoomReservation:on_api_call_timeout()
    self:set_status_failed(500, 'Reservation lookup timed out');
end

--- callback on API response
function RoomReservation:on_api_create_conference_complete(response_body, response_code)
    if response_code == 200 or response_code == 201 then
        self:handler_conference_data_returned_from_api(response_body);
    elseif response_code == 409 then
        self:handle_conference_already_exist(response_body);
    elseif response_code == nil then  -- warrants a retry, but this should be done automatically by the http call method.
        self:set_status_failed(500, 'Could not contact reservation server');
    else
        self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
    end
end

function RoomReservation:handler_conference_data_returned_from_api(response_body)
    local data = self:parse_conference_response(response_body);
    if not data then  -- invalid response from API
        module:log("error", "API returned success code but invalid payload");
        self:set_status_failed(500, 'Invalid response from reservation server');
    else
        self:set_status_success(data.start_time, data.duration, data.mail_owner, data.id, data)
    end
end

function RoomReservation:handle_conference_already_exist(response_body)
    local data = json.decode(response_body);
    if data == nil or data.conflict_id == nil then
        -- yes, in the case of 409, API expected to return "id" as "conflict_id".
        self:set_status_failed(409, 'Invalid response from reservation server');
    else
        local url = api_prefix..'/conference/'..data.conflict_id;
        local http_options = {
            method = 'GET';
            headers = http_headers;
        }

        async_http_request(url, http_options, function(response_body, response_code)
            if response_code == 200 then
                self:handler_conference_data_returned_from_api(response_body);
            else
                self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
            end
        end, function ()
            self:on_api_call_timeout();
        end);
    end
end

--- End RoomReservation

--- Store reservations lookups that are still pending or with room still active
local reservations = {}

local function get_or_create_reservations(room_jid, creator_jid)
    if reservations[room_jid] == nil then
        module:log("debug", "Creating new reservation data for %s", room_jid);
        reservations[room_jid] = newRoomReservation(room_jid, creator_jid);
    end

    return reservations[room_jid];
end

local function evict_expired_reservations()
    local expired = {}

    -- first, gather jids of expired rooms. So we don't remove from table while iterating.
    for room_jid, res in pairs(reservations) do
        if res:is_expired() then
            table.insert(expired, room_jid);
        end
    end

    local room;
    for _, room_jid in ipairs(expired) do
        room = get_room_from_jid(room_jid);
        if room then
            -- Close room if still active (reservation duration exceeded)
            module:log("info", "Room exceeded reservation duration. Terminating %s", room_jid);
            room:destroy(nil, "Scheduled conference duration exceeded.");
            -- Rely on room_destroyed to calls DELETE /conference and drops reservation[room_jid]
        else
            module:log("error", "Reservation references expired room that is no longer active. Dropping %s", room_jid);
            -- This should not happen unless evict_expired_reservations somehow gets triggered
            -- between the time room is destroyed and room_destroyed callback is called. (Possible?)
            -- But just in case, we drop the reservation to avoid repeating this path on every pass.
            reservations[room_jid] = nil;
        end
    end
end

timer.add_task(expiry_check_period, function()
    evict_expired_reservations();
    return expiry_check_period;
end)


--- Intercept conference IQ to Jicofo handle reservation checks before allowing normal event flow
module:log("info", "Hook to global pre-iq/host");
module:hook("pre-iq/host", function(event)
    local stanza = event.stanza;

    if stanza.name ~= "iq" or stanza.attr.to ~= focus_component_host or stanza.attr.type ~= 'set' then
        return;  -- not IQ for jicofo. Ignore this event.
    end

    local conference = stanza:get_child('conference', 'http://jitsi.org/protocol/focus');
    if conference == nil then
        return; -- not Conference IQ. Ignore.
    end

    local room_jid = room_jid_match_rewrite(conference.attr.room);

    if get_room_from_jid(room_jid) ~= nil then
        module:log("debug", "Skip reservation check for existing room %s", room_jid);
        return;  -- room already exists. Continue with normal flow
    end

    if skip_breakout_room then
        local _, host = jid.split(room_jid);
        if host == breakout_muc_component_host then
            module:log("debug", "Skip reservation check for breakout room %s", room_jid);
            return;
        end
    end

    local res = get_or_create_reservations(room_jid, stanza.attr.from);
    res:enqueue_or_route_event(event);  -- hand over to reservation obj to route event
    return true;

end);


--- Forget reservation details once room destroyed so query is repeated if room re-created
local function room_destroyed(event)
    local res;
    local room = event.room

    if not is_healthcheck_room(room.jid) then
        res = reservations[room.jid]

        -- drop reservation data for this room
        reservations[room.jid] = nil

        if res then  -- just in case event triggered more than once?
            module:log("info", "Dropped reservation data for destroyed room %s", room.jid);

            local conflict_id = res.meta.conflict_id
            if conflict_id then
                local url = api_prefix..'/conference/'..conflict_id;
                local http_options = {
                    method = 'DELETE';
                    headers = http_headers;
                }

                module:log("debug", "Sending DELETE /conference/%s", conflict_id);
                async_http_request(url, http_options);
            end
        end
    end
end


local function room_created(event)
    local room = event.room

    if is_healthcheck_room(room.jid) then
        return;
    end

    local res = reservations[room.jid]

    if res and max_occupants_enabled and res.meta.max_occupants ~= nil then
        module:log("info", "Setting max_occupants %d for room %s", res.meta.max_occupants, room.jid);
        room._data.max_occupants = res.meta.max_occupants
    end

    if res and password_support_enabled and res.meta.password ~= nil then
        module:log("info", "Setting password for room %s", room.jid);
        room:set_password(res.meta.password);
    end
end


local function room_pre_create(event)
    local room = event.room

    if is_healthcheck_room(room.jid) then
        return;
    end

    local res = reservations[room.jid]

    if res and lobby_support_enabled and res.meta.lobby then
        module:log("info", "Enabling lobby for room %s", room.jid);
        prosody.events.fire_event("create-persistent-lobby-room", { room = room; });
    end
end

process_host_module(muc_component_host, function(host_module, host)
        module:log("info", "Hook to muc-room-destroyed on %s", host);
        host_module:hook("muc-room-destroyed", room_destroyed, -1);

        if max_occupants_enabled or password_support_enabled then
            module:log("info", "Hook to muc-room-created on %s (max_occupants or password integration enabled)", host);
            host_module:hook("muc-room-created", room_created);
        end

        if lobby_support_enabled then
            module:log("info", "Hook to muc-room-pre-create on %s (lobby integration enabled)", host);
            host_module:hook("muc-room-pre-create", room_pre_create);
        end
end);
