import {
    authenticate,
    getKwArgs,
    logout,
    makeKwArgs,
    MockServer,
    MockServerError,
    models,
    serverState,
    unmakeKwArgs,
} from "@web/../tests/web_test_helpers";
import { serializeDateTime } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { groupBy } from "@web/core/utils/arrays";

export const DISCUSS_ACTION_ID = 104;

/**
 * @template [T={}]
 * @typedef {import("@web/../tests/web_test_helpers").RouteCallback<T>} RouteCallback
 */

const { DateTime } = luxon;

/** @param {import("./mock_model").MailGuest} guest */
export const authenticateGuest = (guest) => {
    const { env } = MockServer;
    /** @type {import("mock_models").ResUsers} */
    const ResUsers = env["res.users"];
    if (!guest?.id) {
        throw new MockServerError("Unauthorized");
    }
    const [publicUser] = ResUsers.read(serverState.publicUserId);
    env.cookie.set("dgid", guest.id);
    authenticate(publicUser.login, publicUser.password);
    env.uid = serverState.publicUserId;
};

/**
 * Executes the given callback as the given guest, then restores the previous user.
 *
 * @param {number} guestId
 * @param {() => any} fn
 */
export async function withGuest(guestId, fn) {
    const { env } = MockServer;
    /** @type {import("mock_models").MailGuest} */
    const MailGuest = env["mail.guest"];
    const currentUser = env.user;
    const [targetGuest] = MailGuest.browse(guestId);
    authenticateGuest(targetGuest);
    let result;
    try {
        result = await fn();
    } finally {
        if (currentUser) {
            authenticate(currentUser.login, currentUser.password);
        } else {
            logout();
            env.cookie.delete("dgid");
        }
    }
    return result;
}

/** @param {Request} request */
export const parseRequestParams = async (request) => {
    const response = await request.json();
    return response.params;
};

const onRpcBeforeGlobal = { cb: (route, args) => {} };
const onRpcAfterGlobal = { cb: (route, args) => {} };
// using a registry category to not expose for manual import
// We should use `onRpcBefore`/`onRpcAfter` with 1st parameter being (route, args) callback function
registry.category("mail.on_rpc_before_global").add(true, onRpcBeforeGlobal);
registry.category("mail.on_rpc_after_global").add(true, onRpcAfterGlobal);
export function registerRoute(route, handler) {
    const beforeCallableHandler = async function (request) {
        let args;
        try {
            args = await parseRequestParams(request);
        } catch {
            args = await request.text();
        }
        let res = await onRpcBeforeGlobal.cb?.(route, args);
        if (res !== undefined) {
            return res;
        }
        res = await beforeCallableHandler.before?.(args);
        if (res !== undefined) {
            return res;
        }
        const response = handler.call(this, request);
        res = await beforeCallableHandler.after?.(response);
        if (res !== undefined) {
            return res;
        }
        return response;
    };
    registry.category("mock_rpc").add(route, beforeCallableHandler);
}

// RPC handlers

registerRoute("/mail/attachment/upload", mail_attachment_upload);
/** @type {RouteCallback}} */
async function mail_attachment_upload(request) {
    /** @type {import("mock_models").DiscussVoiceMetadata} */
    const DiscussVoiceMetadata = this.env["discuss.voice.metadata"];
    /** @type {import("mock_models").IrAttachment} */
    const IrAttachment = this.env["ir.attachment"];

    const body = await request.text();
    const ufile = body.get("ufile");
    const is_pending = body.get("is_pending") === "true";
    const model = is_pending ? "mail.compose.message" : body.get("thread_model");
    const id = is_pending ? 0 : parseInt(body.get("thread_id"));
    const attachmentId = IrAttachment.create({
        // datas,
        mimetype: ufile.type,
        name: ufile.name,
        res_id: id,
        res_model: model,
    });
    if (body.get("voice")) {
        DiscussVoiceMetadata.create({ attachment_id: attachmentId });
    }
    return {
        data: new mailDataHelpers.Store(IrAttachment.browse(attachmentId)).get_result(),
    };
}

registerRoute("/mail/attachment/delete", mail_attachment_delete);
/** @type {RouteCallback} */
async function mail_attachment_delete(request) {
    /** @type {import("mock_models").BusBus} */
    const BusBus = this.env["bus.bus"];
    /** @type {import("mock_models").IrAttachment} */
    const IrAttachment = this.env["ir.attachment"];
    /** @type {import("mock_models").ResPartner} */
    const ResPartner = this.env["res.partner"];

    const { attachment_id } = await parseRequestParams(request);
    const [partner] = ResPartner.read(this.env.user.partner_id);
    BusBus._sendone(partner, "ir.attachment/delete", {
        id: attachment_id,
    });
    return IrAttachment.unlink([attachment_id]);
}

registerRoute("/discuss/channel/attachments", load_attachments);
/** @type {RouteCallback} */
async function load_attachments(request) {
    /** @type {import("mock_models").IrAttachment} */
    const IrAttachment = this.env["ir.attachment"];

    const {
        channel_id,
        limit = 30,
        older_attachment_id = null,
    } = await parseRequestParams(request);
    const attachmentIds = IrAttachment.filter(
        ({ id, res_id, res_model }) =>
            res_id === channel_id &&
            res_model === "discuss.channel" &&
            (!older_attachment_id || id < older_attachment_id)
    )
        .sort()
        .slice(0, limit)
        .map(({ id }) => id);
    return new mailDataHelpers.Store(IrAttachment.browse(attachmentIds)).get_result();
}

registerRoute("/mail/rtc/channel/join_call", channel_call_join);
/** @type {RouteCallback} */
async function channel_call_join(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    /** @type {import("mock_models").DiscussChannelMember} */
    const DiscussChannelMember = this.env["discuss.channel.member"];
    /** @type {import("mock_models").DiscussChannelRtcSession} */
    const DiscussChannelRtcSession = this.env["discuss.channel.rtc.session"];

    const { channel_id } = await parseRequestParams(request);
    const memberOfCurrentUser = DiscussChannel._find_or_create_member_for_self(channel_id);
    const sessionId = DiscussChannelRtcSession.create({
        channel_member_id: memberOfCurrentUser.id,
        channel_id, // on the server, this is a related field from channel_member_id and not explicitly set
        guest_id: memberOfCurrentUser.guest_id,
        partner_id: memberOfCurrentUser.partner_id,
    });
    const channelMembers = DiscussChannelMember._filter([["channel_id", "=", channel_id]]);
    const rtcSessions = DiscussChannelRtcSession._filter([
        ["channel_member_id", "in", channelMembers.map((channelMember) => channelMember.id)],
    ]);
    return new mailDataHelpers.Store(DiscussChannel.browse(channel_id), {
        rtcSessions: mailDataHelpers.Store.many(rtcSessions, "ADD"),
    })
        .add("Rtc", {
            iceServers: false,
            selfSession: mailDataHelpers.Store.one(DiscussChannelRtcSession.browse(sessionId)),
        })
        .get_result();
}

registerRoute("/mail/rtc/channel/leave_call", channel_call_leave);
/** @type {RouteCallback} */
async function channel_call_leave(request) {
    /** @type {import("mock_models").BusBus} */
    const BusBus = this.env["bus.bus"];
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    /** @type {import("mock_models").DiscussChannelMember} */
    const DiscussChannelMember = this.env["discuss.channel.member"];
    /** @type {import("mock_models").DiscussChannelRtcSession} */
    const DiscussChannelRtcSession = this.env["discuss.channel.rtc.session"];
    /** @type {import("mock_models").MailGuest} */
    const MailGuest = this.env["mail.guest"];
    /** @type {import("mock_models").ResPartner} */
    const ResPartner = this.env["res.partner"];

    const { channel_id } = await parseRequestParams(request);
    const channelMembers = DiscussChannelMember._filter([["channel_id", "=", channel_id]]);
    const rtcSessions = DiscussChannelRtcSession._filter([
        ["channel_member_id", "in", channelMembers.map((channelMember) => channelMember.id)],
    ]);
    const notifications = [];
    const sessionsByChannelId = {};
    for (const session of rtcSessions) {
        const [member] = DiscussChannelMember.browse(session.channel_member_id);
        if (!sessionsByChannelId[member.channel_id]) {
            sessionsByChannelId[member.channel_id] = [];
        }
        sessionsByChannelId[member.channel_id].push(session);
    }
    for (const [channelId, sessions] of Object.entries(sessionsByChannelId)) {
        const channel = DiscussChannel.search_read([["id", "=", parseInt(channelId)]])[0];
        notifications.push([
            channel,
            "mail.record/insert",
            new mailDataHelpers.Store(DiscussChannel.browse(Number(channelId)), {
                rtcSessions: mailDataHelpers.Store.many(
                    DiscussChannelRtcSession.browse(sessions.map((session) => session.id)),
                    "DELETE",
                    makeKwArgs({ only_id: true })
                ),
            }).get_result(),
        ]);
    }
    for (const rtcSession of rtcSessions) {
        const target = rtcSession.guest_id
            ? MailGuest.search_read([["id", "=", rtcSession.guest_id]])[0]
            : ResPartner.search_read([["id", "=", rtcSession.partner_id]])[0];
        notifications.push([
            target,
            "discuss.channel.rtc.session/ended",
            { sessionId: rtcSession.id },
        ]);
    }
    BusBus._sendmany(notifications);
}

registerRoute("/discuss/channel/fold", discuss_channel_fold);
/** @type {RouteCallback} */
async function discuss_channel_fold(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    /** @type {import("mock_models").DiscussChannelMember} */
    const DiscussChannelMember = this.env["discuss.channel.member"];

    const { channel_id, state, state_count } = await parseRequestParams(request);
    const memberOfCurrentUser = DiscussChannel._find_or_create_member_for_self(channel_id);
    return DiscussChannelMember._channel_fold(memberOfCurrentUser.id, state, state_count);
}

registerRoute("/discuss/channel/info", discuss_channel_info);
/** @type {RouteCallback} */
async function discuss_channel_info(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];

    const { channel_id } = await parseRequestParams(request);
    const channel = DiscussChannel.search([["id", "=", channel_id]]);
    if (!channel.length) {
        return;
    }
    return new mailDataHelpers.Store(channel).get_result();
}

registerRoute("/discuss/channel/members", discuss_channel_members);
/** @type {RouteCallback} */
async function discuss_channel_members(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];

    const { channel_id, known_member_ids } = await parseRequestParams(request);
    return DiscussChannel._load_more_members([channel_id], known_member_ids);
}

registerRoute("/discuss/channel/messages", discuss_channel_messages);
/** @type {RouteCallback} */
async function discuss_channel_messages(request) {
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];

    const {
        after,
        around,
        before,
        channel_id,
        limit = 30,
        search_term,
    } = await parseRequestParams(request);
    const domain = [
        ["res_id", "=", channel_id],
        ["model", "=", "discuss.channel"],
        ["message_type", "!=", "user_notification"],
    ];
    const res = MailMessage._message_fetch(domain, search_term, before, after, around, limit);
    const { messages } = res;
    delete res.messages;
    if (!around) {
        MailMessage.set_message_done(messages.map((message) => message.id));
    }
    return {
        ...res,
        data: new mailDataHelpers.Store(
            MailMessage.browse(messages.map((message) => message.id)),
            makeKwArgs({ for_current_user: true })
        ).get_result(),
        messages: mailDataHelpers.Store.many_ids(messages),
    };
}

registerRoute("/discuss/channel/sub_channel/create", discuss_channel_sub_channel_create);
async function discuss_channel_sub_channel_create(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    const { from_message_id, parent_channel_id, name } = await parseRequestParams(request);
    return DiscussChannel._create_sub_channel(
        [parent_channel_id],
        makeKwArgs({ from_message_id, name })
    );
}

registerRoute("/discuss/channel/sub_channel/fetch", discuss_channel_sub_channel_fetch);
async function discuss_channel_sub_channel_fetch(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];
    const { parent_channel_id, before, limit } = await parseRequestParams(request);
    const domain = [["parent_channel_id", "=", parent_channel_id]];
    if (before) {
        domain.push(["id", "<", before]);
    }
    const subChannels = DiscussChannel.search(domain, makeKwArgs({ limit, order: "id DESC" }));
    const store = new mailDataHelpers.Store(subChannels);
    const lastMessageIds = [];
    for (const channel of subChannels) {
        const lastMessageId = Math.max(channel.message_ids);
        if (lastMessageId) {
            lastMessageIds.push(lastMessageId);
        }
    }
    store.add(MailMessage.browse(lastMessageIds));
    return store.get_result();
}

registerRoute("/discuss/settings/mute", discuss_settings_mute);
/** @type {RouteCallback} */
async function discuss_settings_mute(request) {
    /** @type {import("mock_models").BusBus} */
    const BusBus = this.env["bus.bus"];
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    /** @type {import("mock_models").ResUsersSettings} */
    const ResUsersSettings = this.env["res.users.settings"];
    /** @type {import("mock_models").DiscussChannelMember} */
    const DiscussChannelMember = this.env["discuss.channel.member"];
    /** @type {import("mock_models").ResPartner} */
    const ResPartner = this.env["res.partner"];

    const { channel_id, minutes } = await parseRequestParams(request);
    let mute_until_dt;
    if (minutes === -1) {
        mute_until_dt = serializeDateTime(DateTime.fromISO("9999-12-31T23:59:59"));
    } else if (minutes) {
        mute_until_dt = serializeDateTime(DateTime.now().plus({ minutes }));
    } else {
        mute_until_dt = false;
    }
    if (channel_id) {
        const member = DiscussChannel._find_or_create_member_for_self(channel_id);
        DiscussChannelMember.write([member.id], { mute_until_dt });
        const [partner] = ResPartner.read(this.env.user.partner_id);
        BusBus._sendone(
            partner,
            "mail.record/insert",
            new mailDataHelpers.Store(DiscussChannel.browse(member.channel_id), {
                mute_until_dt,
            }).get_result()
        );
    } else {
        const settings = ResUsersSettings._find_or_create_for_user(this.env.user.id);
        ResUsersSettings.set_res_users_settings(settings.id, { mute_until_dt });
    }
    return "dummy";
}

registerRoute("/discuss/channel/notify_typing", discuss_channel_notify_typing);
/** @type {RouteCallback} */
async function discuss_channel_notify_typing(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    /** @type {import("mock_models").DiscussChannelMember} */
    const DiscussChannelMember = this.env["discuss.channel.member"];

    const { channel_id, is_typing } = await parseRequestParams(request);
    const memberOfCurrentUser = DiscussChannel._find_or_create_member_for_self(channel_id);
    if (!memberOfCurrentUser) {
        return;
    }
    DiscussChannelMember.notify_typing([memberOfCurrentUser.id], is_typing);
}

registerRoute("/discuss/channel/ping", channel_ping);
/** @type {RouteCallback} */
async function channel_ping(request) {}

registerRoute("/discuss/channel/pinned_messages", discuss_channel_pins);
/** @type {RouteCallback} */
async function discuss_channel_pins(request) {
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];

    const { channel_id } = await parseRequestParams(request);
    const messageIds = MailMessage.search([
        ["model", "=", "discuss.channel"],
        ["res_id", "=", channel_id],
        ["pinned_at", "!=", false],
    ]);
    return new mailDataHelpers.Store(
        messageIds,
        makeKwArgs({ for_current_user: true })
    ).get_result();
}

registerRoute("/discuss/channel/mark_as_read", discuss_channel_mark_as_read);
/** @type {RouteCallback} */
async function discuss_channel_mark_as_read(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannelMember = this.env["discuss.channel.member"];
    const { channel_id, last_message_id, sync } = await parseRequestParams(request);
    const [partner, guest] = this.env["res.partner"]._get_current_persona();
    const [memberId] = this.env["discuss.channel.member"].search([
        ["channel_id", "=", channel_id],
        partner ? ["partner_id", "=", partner.id] : ["guest_id", "=", guest.id],
    ]);
    if (!memberId) {
        return; // ignore if the member left in the meantime
    }
    return DiscussChannelMember._mark_as_read([memberId], last_message_id, sync);
}

registerRoute("/discuss/channel/mark_as_unread", discuss_channel_mark_as_unread);
/** @type {RouteCallback} */
async function discuss_channel_mark_as_unread(request) {
    const { channel_id, message_id } = await parseRequestParams(request);
    const [partner, guest] = this.env["res.partner"]._get_current_persona();
    const [memberId] = this.env["discuss.channel.member"].search([
        ["channel_id", "=", channel_id],
        partner ? ["partner_id", "=", partner.id] : ["guest_id", "=", guest.id],
    ]);
    return this.env["discuss.channel.member"]._set_new_message_separator(
        [memberId],
        message_id,
        true
    );
}

registerRoute("/discuss/gif/favorites", get_favorites);
/** @type {RouteCallback} */
async function get_favorites(request) {
    return [[]];
}

registerRoute("/mail/history/messages", discuss_history_messages);
/** @type {RouteCallback} */
async function discuss_history_messages(request) {
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];
    /** @type {import("mock_models").MailNotification} */
    const MailNotification = this.env["mail.notification"];

    const { after, around, before, limit = 30, search_term } = await parseRequestParams(request);
    const domain = [["needaction", "=", false]];
    const res = MailMessage._message_fetch(domain, search_term, before, after, around, limit);
    const { messages } = res;
    delete res.messages;
    const messagesWithNotification = messages.filter((message) => {
        const notifs = MailNotification.search_read([
            ["mail_message_id", "=", message.id],
            ["is_read", "=", true],
            ["res_partner_id", "=", this.env.user.partner_id],
        ]);
        return notifs.length > 0;
    });
    return {
        ...res,
        data: new mailDataHelpers.Store(
            MailMessage.browse(messagesWithNotification.map((message) => message.id)),
            makeKwArgs({ for_current_user: true })
        ).get_result(),
        messages: mailDataHelpers.Store.many_ids(messages),
    };
}

registerRoute("/mail/inbox/messages", discuss_inbox_messages);
/** @type {RouteCallback} */
async function discuss_inbox_messages(request) {
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];

    const { after, around, before, limit = 30, search_term } = await parseRequestParams(request);
    const domain = [["needaction", "=", true]];
    const res = MailMessage._message_fetch(domain, search_term, before, after, around, limit);
    const { messages } = res;
    delete res.messages;
    return {
        ...res,
        data: new mailDataHelpers.Store(
            MailMessage.browse(messages.map((message) => message.id)),
            makeKwArgs({ for_current_user: true, add_followers: true })
        ).get_result(),
        messages: mailDataHelpers.Store.many_ids(messages),
    };
}

registerRoute("/mail/link_preview", mail_link_preview);
/** @type {RouteCallback} */
async function mail_link_preview(request) {
    /** @type {import("mock_models").BusBus} */
    const BusBus = this.env["bus.bus"];
    /** @type {import("mock_models").MailLinkPreview} */
    const MailLinkPreview = this.env["mail.link.preview"];
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];

    const { message_id } = await parseRequestParams(request);
    const [message] = MailMessage.search_read([["id", "=", message_id]]);
    if (message.body.includes("https://make-link-preview.com")) {
        const linkPreviewId = MailLinkPreview.create({
            message_id: message.id,
            og_description: "test description",
            og_title: "Article title",
            og_type: "article",
            source_url: "https://make-link-preview.com",
        });
        BusBus._sendone(
            MailMessage._bus_notification_target(message_id),
            "mail.record/insert",
            new mailDataHelpers.Store(MailLinkPreview.browse(linkPreviewId)).get_result()
        );
    }
}

registerRoute("/mail/link_preview/hide", mail_link_preview_hide);
/** @type {RouteCallback} */
async function mail_link_preview_hide(request) {
    /** @type {import("mock_models").BusBus} */
    const BusBus = this.env["bus.bus"];
    /** @type {import("mock_models").MailLinkPreview} */
    const MailLinkPreview = this.env["mail.link.preview"];
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];

    const { link_preview_ids } = await parseRequestParams(request);
    for (const linkPreview of MailLinkPreview.browse(link_preview_ids)) {
        BusBus._sendone(
            MailMessage._bus_notification_target(linkPreview.message_id),
            "mail.record/insert",
            new mailDataHelpers.Store(MailMessage.browse(linkPreview.message_id), {
                linkPreviews: mailDataHelpers.Store.many(
                    MailLinkPreview.browse(linkPreview.id),
                    "DELETE",
                    makeKwArgs({ only_id: true })
                ),
            }).get_result()
        );
    }
    return { link_preview_ids };
}

registerRoute("/mail/message/post", mail_message_post);
/** @type {RouteCallback} */
export async function mail_message_post(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];
    /** @type {import("mock_models").MailThread} */
    const MailThread = this.env["mail.thread"];
    /** @type {import("mock_models").ResPartner} */
    const ResPartner = this.env["res.partner"];

    const {
        context,
        post_data,
        thread_id,
        thread_model,
        partner_emails,
        partner_additional_values,
        canned_response_ids,
    } = await parseRequestParams(request);
    if (canned_response_ids) {
        for (const cannedResponseId of canned_response_ids) {
            this.env["mail.canned.response"].write([cannedResponseId], {
                last_used: serializeDateTime(DateTime.now()),
            });
        }
    }
    if (partner_emails) {
        post_data.partner_ids = post_data.partner_ids || [];
        for (const email of partner_emails) {
            const partner = ResPartner._filter([["email", "=", email]]);
            if (partner.length !== 0) {
                post_data.partner_ids.push(partner[0].id);
            } else {
                const partner_id = ResPartner.create(
                    Object.assign({ email }, partner_additional_values[email] || {})
                );
                post_data.partner_ids.push(partner_id);
            }
        }
    }
    const finalData = {};
    const allowedParams = [
        "attachment_ids",
        "body",
        "message_type",
        "partner_ids",
        "subtype_xmlid",
    ];
    if (thread_model === "discuss.channel") {
        allowedParams.push("parent_id", "special_mentions");
    }
    for (const allowedParam of allowedParams) {
        if (post_data[allowedParam] !== undefined) {
            finalData[allowedParam] = post_data[allowedParam];
        }
    }
    const kwargs = makeKwArgs({ ...finalData, context });
    let messageId;
    if (thread_model === "discuss.channel") {
        messageId = DiscussChannel.message_post(thread_id, kwargs);
    } else {
        const model = this.env[thread_model];
        messageId = MailThread.message_post.call(model, [thread_id], {
            ...kwargs,
            model: thread_model,
        });
    }
    return new mailDataHelpers.Store(
        MailMessage.browse(messageId),
        makeKwArgs({ for_current_user: true })
    ).get_result();
}

registerRoute("/mail/message/reaction", mail_message_reaction);
/** @type {RouteCallback} */
async function mail_message_reaction(request) {
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];
    const { action, content, message_id } = await parseRequestParams(request);
    const partner_id = this.env.user?.partner_id ?? false;
    const guest_id = this.env.cookie.get("dgid") ?? false;
    const store = new mailDataHelpers.Store();
    MailMessage._message_reaction(message_id, content, partner_id, guest_id, action, store);
    return store.get_result();
}

registerRoute("/mail/message/translate", translate);
/** @type {RouteCallback} */
async function translate(request) {}

registerRoute("/mail/message/update_content", mail_message_update_content);
/** @type {RouteCallback} */
async function mail_message_update_content(request) {
    /** @type {import("mock_models").BusBus} */
    const BusBus = this.env["bus.bus"];
    /** @type {import("mock_models").IrAttachment} */
    const IrAttachment = this.env["ir.attachment"];
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];

    const { attachment_ids, body, message_id } = await parseRequestParams(request);
    const [message] = MailMessage.browse(message_id);
    const msg_values = {};
    if (body !== null) {
        const edit_label = "<span class='o-mail-Message-edited'/>";
        msg_values.body = body === "" && attachment_ids.length === 0 ? "" : body + edit_label;
    }
    if (attachment_ids.length === 0) {
        IrAttachment.unlink(message.attachment_ids);
    } else {
        const attachments = IrAttachment.browse(attachment_ids).filter(
            (attachment) =>
                attachment.res_model === "mail.compose.message" &&
                attachment.create_uid === this.env.user?.id
        );
        IrAttachment.write(
            attachments.map((attachment) => attachment.id),
            {
                model: message.model,
                res_id: message.res_id,
            }
        );
        msg_values.attachment_ids = attachment_ids;
    }
    MailMessage.write([message_id], msg_values);
    if (body === "" && attachment_ids.length === 0) {
        MailMessage.write([message_id], { pinned_at: false });
        MailMessage._cleanup_side_records([message_id]);
    }
    BusBus._sendone(
        MailMessage._bus_notification_target(message.id),
        "mail.record/insert",
        new mailDataHelpers.Store(MailMessage.browse(message.id), {
            attachment_ids: mailDataHelpers.Store.many(IrAttachment.browse(message.attachment_ids)),
            body: message.body,
            pinned_at: message.pinned_at,
            recipients: mailDataHelpers.Store.many(
                this.env["res.partner"].browse(message.partner_ids),
                makeKwArgs({ fields: ["name", "write_date"] })
            ),
        }).get_result()
    );
    return new mailDataHelpers.Store(
        MailMessage.browse(message_id),
        makeKwArgs({ for_current_user: true })
    ).get_result();
}

registerRoute("/discuss/channel/<int:cid>/partner/<int:pid>/avatar_128", partnerAvatar128);
/** @type {RouteCallback} */
async function partnerAvatar128(request, { cid, pid }) {
    return [cid, pid];
}

registerRoute("/mail/partner/from_email", mail_thread_partner_from_email);
/** @type {RouteCallback} */
async function mail_thread_partner_from_email(request) {
    /** @type {import("mock_models").ResPartner} */
    const ResPartner = this.env["res.partner"];

    const { emails, additional_values = {} } = await parseRequestParams(request);
    const partners = emails.map((email) => ResPartner.search([["email", "=", email]])[0]);
    for (const index in partners) {
        if (!partners[index]) {
            const email = emails[index];
            partners[index] = ResPartner.create({
                email,
                name: email,
                ...(additional_values[email] || {}),
            });
        }
    }
    return partners.map((partner_id) => {
        const [partner] = ResPartner.browse(partner_id);
        return { id: partner_id, name: partner.name, email: partner.email };
    });
}

registerRoute("/mail/read_subscription_data", read_subscription_data);
/** @type {RouteCallback} */
async function read_subscription_data(request) {
    /** @type {import("mock_models").MailFollowers} */
    const MailFollowers = this.env["mail.followers"];
    /** @type {import("mock_models").MailMessageSubtype} */
    const MailMessageSubtype = this.env["mail.message.subtype"];

    const { follower_id } = await parseRequestParams(request);
    const [follower] = MailFollowers.browse(follower_id);
    const subtypes = MailMessageSubtype._filter([
        "&",
        ["hidden", "=", false],
        "|",
        ["res_model", "=", follower.res_model],
        ["res_model", "=", false],
    ]);
    const subtypes_list = subtypes.map((subtype) => {
        const [parent] = MailMessageSubtype.browse(subtype.parent_id);
        return {
            default: subtype.default,
            followed: follower.subtype_ids.includes(subtype.id),
            id: subtype.id,
            internal: subtype.internal,
            name: subtype.name,
            parent_model: parent ? parent.res_model : false,
            res_model: subtype.res_model,
            sequence: subtype.sequence,
        };
    });
    // NOTE: server is also doing a sort here, not reproduced for simplicity
    return subtypes_list;
}

registerRoute("/mail/rtc/session/update_and_broadcast", session_update_and_broadcast);
/** @type {RouteCallback} */
async function session_update_and_broadcast(request) {
    /** @type {import("mock_models").DiscussChannelMember} */
    const DiscussChannelMember = this.env["discuss.channel.member"];
    /** @type {import("mock_models").DiscussChannelRtcSession} */
    const DiscussChannelRtcSession = this.env["discuss.channel.rtc.session"];

    const { session_id, values } = await parseRequestParams(request);
    const [session] = DiscussChannelRtcSession.search_read([["id", "=", session_id]]);
    const [currentChannelMember] = DiscussChannelMember.search_read([
        ["id", "=", session.channel_member_id[0]],
    ]);
    if (session && currentChannelMember.partner_id[0] === serverState.partnerId) {
        DiscussChannelRtcSession._update_and_broadcast(session.id, values);
    }
}

registerRoute("/mail/starred/messages", discuss_starred_messages);
/** @type {RouteCallback} */
async function discuss_starred_messages(request) {
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];

    const { after, before, limit = 30, search_term } = await parseRequestParams(request);
    const domain = [["starred_partner_ids", "in", [this.env.user.partner_id]]];
    const res = MailMessage._message_fetch(domain, search_term, before, after, false, limit);
    const { messages } = res;
    delete res.messages;
    return {
        ...res,
        data: new mailDataHelpers.Store(
            MailMessage.browse(messages.map((message) => message.id)),
            makeKwArgs({ for_current_user: true })
        ).get_result(),
        messages: mailDataHelpers.Store.many_ids(messages),
    };
}

registerRoute("/mail/thread/data", mail_thread_data);
/** @type {RouteCallback} */
export async function mail_thread_data(request) {
    const { request_list, thread_model, thread_id } = await parseRequestParams(request);
    const [thread] = this.env[thread_model].browse(thread_id);
    if (!thread) {
        return new mailDataHelpers.Store("mail.thread", {
            hasReadAccess: false,
            hasWriteAccess: false,
            id: thread_id,
            model: thread_model,
        }).get_result();
    }
    return new mailDataHelpers.Store(
        this.env[thread_model].browse(thread_id),
        makeKwArgs({ as_thread: true, request_list })
    ).get_result();
}

registerRoute("/mail/thread/messages", mail_thread_messages);
/** @type {RouteCallback} */
async function mail_thread_messages(request) {
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];

    const { after, around, before, limit, search_term, thread_id, thread_model } =
        await parseRequestParams(request);
    const domain = [
        ["res_id", "=", thread_id],
        ["model", "=", thread_model],
        ["message_type", "!=", "user_notification"],
    ];
    const res = MailMessage._message_fetch(domain, search_term, before, after, around, limit);
    const { messages } = res;
    delete res.messages;
    MailMessage.set_message_done(messages.map((message) => message.id));
    return {
        ...res,
        data: new mailDataHelpers.Store(
            MailMessage.browse(messages.map((message) => message.id)),
            makeKwArgs({ for_current_user: true })
        ).get_result(),
        messages: mailDataHelpers.Store.many_ids(messages),
    };
}

registerRoute("/mail/action", mail_action);
/** @type {RouteCallback} */
async function mail_action(request) {
    return (await mailDataHelpers.processRequest.call(this, request)).get_result();
}

registerRoute("/mail/data", mail_data);
/** @type {RouteCallback} */
async function mail_data(request) {
    return (await mailDataHelpers.processRequest.call(this, request)).get_result();
}

/** @type {RouteCallback} */
async function processRequest(request) {
    /** @type {import("mock_models").DiscussChannel} */
    const DiscussChannel = this.env["discuss.channel"];
    /** @type {import("mock_models").DiscussChannelMember} */
    const DiscussChannelMember = this.env["discuss.channel.member"];
    /** @type {import("mock_models").MailGuest} */
    const MailGuest = this.env["mail.guest"];
    /** @type {import("mock_models").MailMessage} */
    const MailMessage = this.env["mail.message"];
    /** @type {import("mock_models").MailNotification} */
    const MailNotification = this.env["mail.notification"];
    /** @type {import("mock_models").ResPartner} */
    const ResPartner = this.env["res.partner"];
    /** @type {import("mock_models").ResUsers} */
    const ResUsers = this.env["res.users"];

    const store = new mailDataHelpers.Store();
    const args = await parseRequestParams(request);
    if ("init_messaging" in args) {
        if (!MailGuest._get_guest_from_context() || !ResUsers._is_public(this.env.uid)) {
            ResUsers._init_messaging([this.env.uid], store, args.context);
        }
        const guest = ResUsers._is_public(this.env.uid) && MailGuest._get_guest_from_context();
        const members = DiscussChannelMember._filter([
            guest ? ["guest_id", "=", guest.id] : ["partner_id", "=", this.env.user.partner_id],
            "|",
            ["fold_state", "in", ["open", "folded"]],
            ["rtc_inviting_session_id", "!=", false],
        ]);
        const channelsDomain = [["id", "in", members.map((m) => m.channel_id)]];
        const { channelTypes } = args.init_messaging;
        if (channelTypes) {
            channelsDomain.push(["channel_type", "in", channelTypes]);
        }
        store.add(DiscussChannel.search(channelsDomain));
    }
    if (args.failures && this.env.user?.partner_id) {
        const [partner] = ResPartner.browse(this.env.user.partner_id);
        const messages = MailMessage._filter([
            ["author_id", "=", partner.id],
            ["res_id", "!=", 0],
            ["model", "!=", false],
            ["message_type", "!=", "user_notification"],
        ]).filter((message) => {
            // Purpose is to simulate the following domain on mail.message:
            // ['notification_ids.notification_status', 'in', ['bounce', 'exception']],
            // But it's not supported by getRecords domain to follow a relation.
            const notifications = MailNotification._filter([
                ["mail_message_id", "=", message.id],
                ["notification_status", "in", ["bounce", "exception"]],
            ]);
            return notifications.length > 0;
        });
        messages.length = Math.min(messages.length, 100);
        MailMessage._message_notifications_to_store(
            messages.map((message) => message.id),
            store
        );
    }
    if (args.systray_get_activities && this.env.user?.partner_id) {
        const bus_last_id = this.env["bus.bus"].lastBusNotificationId;
        const groups = ResUsers._get_activity_groups();
        store.add({
            activityCounter: groups.reduce(
                (counter, group) => counter + (group.total_count || 0),
                0
            ),
            activity_counter_bus_id: bus_last_id,
            activityGroups: groups,
        });
    }
    if (args.channels_as_member) {
        const channels = DiscussChannel._get_channels_as_member();
        store.add(
            MailMessage.browse(
                channels
                    .map(
                        (channel) =>
                            MailMessage._filter([
                                ["model", "=", "discuss.channel"],
                                ["res_id", "=", channel.id],
                            ]).sort((a, b) => b.id - a.id)[0]
                    )
                    .filter((lastMessage) => lastMessage)
                    .map((message) => message.id)
            ),
            makeKwArgs({ for_current_user: true })
        );
        store.add(channels.map((channel) => channel.id));
    }
    if (args.canned_responses) {
        const domain = [
            "|",
            ["create_uid", "=", this.env.user.id],
            ["group_ids", "in", this.env.user.groups_id],
        ];
        store.add(this.env["mail.canned.response"].search(domain));
    }
    return store;
}

const ids_by_model = {
    "mail.thread": ["model", "id"],
    MessageReactions: ["message", "content"],
    Rtc: [],
    Store: [],
};

const MANY = Symbol("MANY");
const ONE = Symbol("ONE");

class Store {
    constructor(data, values, as_thread, _delete, kwargs) {
        this.data = new Map();
        if (data) {
            this.add(...arguments);
        }
    }

    add(data, values, as_thread, _delete, kwargs) {
        if (!data) {
            return this;
        }
        kwargs = unmakeKwArgs(getKwArgs(arguments, "data", "values", "as_thread", "delete"));
        data = kwargs.data;
        delete kwargs.data;
        values = kwargs.values;
        delete kwargs.values;
        as_thread = kwargs.as_thread;
        delete kwargs.as_thread;
        _delete = kwargs.delete ?? false;
        delete kwargs.delete;
        let model_name;
        if (data instanceof models.Model) {
            if (values) {
                if (data.length !== 1) {
                    throw new Error(`expected single recordset ${data} with values`);
                }
                if (Object.keys(kwargs).length) {
                    throw new Error(
                        `expected empty kwargs with recordset ${data} values: ${kwargs}`
                    );
                }
                if (_delete) {
                    throw new Error(`deleted not expected for ${data} with values: ${values}`);
                }
            }
            if (_delete) {
                if (data.length !== 1) {
                    throw new Error(`expected single record ${data} with delete`);
                }
                if (values) {
                    throw new Error(`for ${data} expected empty values with delete: ${values}`);
                }
            }
            const ids = data.map((idOrRecord) =>
                typeof idOrRecord === "number" ? idOrRecord : idOrRecord.id
            );
            if (as_thread) {
                if (_delete) {
                    this.add(
                        "mail.thread",
                        { id: data[0].id, model: data._name },
                        makeKwArgs({ delete: _delete })
                    );
                } else if (values) {
                    this.add("mail.thread", { id: data[0].id, model: data._name, ...values });
                } else {
                    MockServer.env["mail.thread"]._thread_to_store.call(
                        MockServer.env[data._name],
                        ids,
                        this,
                        makeKwArgs(kwargs)
                    );
                }
            } else {
                if (_delete) {
                    this.add(data._name, { id: ids[0] }, makeKwArgs({ delete: _delete }));
                } else if (values) {
                    this.add(data._name, { id: ids[0], ...values });
                } else {
                    MockServer.env[data._name]._to_store(ids, this, makeKwArgs(kwargs));
                }
            }
            return this;
        } else if (typeof data === "object") {
            if (values) {
                throw new Error(`expected empty values with dict ${data}: ${values}`);
            }
            if (Object.keys(kwargs).length) {
                throw new Error(`expected empty kwargs with dict ${data}: ${kwargs}`);
            }
            if (as_thread) {
                throw new Error(`expected not as_thread with dict ${data}: ${values}`);
            }
            model_name = "Store";
            values = data;
        } else {
            if (Object.keys(kwargs).length) {
                throw new Error(`expected empty kwargs with model name ${data}: ${kwargs}`);
            }
            if (as_thread) {
                throw new Error(`expected not as_thread with model name ${data}: ${values}`);
            }
            model_name = data;
        }
        if (typeof model_name !== "string") {
            throw new Error(`expected string for model name: ${model_name}: ${values}`);
        }
        const ids = ids_by_model[model_name] || ["id"];
        // handle singleton model: update single record in place
        if (!ids.length) {
            if (typeof values !== "object") {
                throw new Error(`expected dict for singleton ${model_name}: ${values}`);
            }
            if (_delete) {
                throw new Error(`Singleton ${model_name} cannot be deleted`);
            }
            if (!this.data.has(model_name)) {
                this.data.set(model_name, {});
            }
            this._add_values(values, model_name);
            return this;
        }
        // handle model with ids: add or update existing records based on ids
        if (!Array.isArray(values)) {
            if (!values) {
                return this;
            }
            values = [values];
        }
        if (!values.length) {
            return this;
        }
        let records = this.data.get(model_name);
        if (!records) {
            records = new Map();
            this.data.set(model_name, records);
        }
        for (const vals of values) {
            if (typeof vals !== "object") {
                throw new Error(`expected dict for ${model_name}: ${vals}`);
            }
            for (const i of ids) {
                if (!vals[i]) {
                    throw new Error(`missing id ${i} in ${model_name}: ${vals}`);
                }
            }
            const index = ids.map((i) => vals[i]).join(" AND ");
            if (!records.has(index)) {
                records.set(index, {});
            }
            this._add_values(vals, model_name, index);
            if (_delete) {
                records.get(index)._DELETE = true;
            } else {
                delete records.get(index)._DELETE;
            }
        }
        return this;
    }

    _add_values(values, model_name, index) {
        const target = index ? this.data.get(model_name).get(index) : this.data.get(model_name);
        for (const [key, val] of Object.entries(values)) {
            if (key === "_DELETE") {
                throw new Error(`invalid key ${key} in ${model_name}: ${values}`);
            }
            if (Array.isArray(val) && val[0] === ONE) {
                const [, subrecord, as_thread, only_id, subrecord_kwargs] = val;
                if (subrecord && !(subrecord instanceof models.Model)) {
                    throw new Error(`expected recordset for one ${key}: ${subrecord}`);
                }
                if (subrecord && subrecord.length && !only_id) {
                    this.add(subrecord, makeKwArgs({ as_thread, ...subrecord_kwargs }));
                }
                target[key] = Store.one_id(subrecord, makeKwArgs({ as_thread }));
            } else if (Array.isArray(val) && val[0] === MANY) {
                const [, subrecords, mode, as_thread, only_id, subrecords_kwargs] = val;
                if (subrecords && !(subrecords instanceof models.Model)) {
                    throw new Error(`expected recordset for many ${key}: ${subrecords}`);
                }
                if (!["ADD", "DELETE", "REPLACE"].includes(mode)) {
                    throw new Error(`invalid mode for many ${key}: ${mode} `);
                }
                if (subrecords && subrecords.length && !only_id) {
                    this.add(subrecords, makeKwArgs({ as_thread, ...subrecords_kwargs }));
                }
                const rel_val = Store.many_ids(subrecords, mode, makeKwArgs({ as_thread }));
                target[key] =
                    key in target && mode !== "REPLACE" ? target[key].concat(rel_val) : rel_val;
            } else {
                target[key] = val;
            }
        }
    }

    get_result() {
        const res = {};
        for (const [model_name, records] of this.data) {
            const ids = ids_by_model[model_name] || ["id"];
            if (!ids.length) {
                // singleton
                res[model_name] = { ...records };
            } else {
                res[model_name] = [...records.values()].map((record) => ({ ...record }));
            }
        }
        return res;
    }

    toJSON() {
        throw Error(
            "Converting Store to JSON is not supported, you might want to call 'get_result()' instead."
        );
    }

    static many(records, mode = "REPLACE", as_thread, only_id, kwargs) {
        kwargs = getKwArgs(arguments, "records", "mode");
        records = kwargs.records;
        delete kwargs.records;
        mode = kwargs.mode ?? "REPLACE";
        delete kwargs.mode;
        as_thread = kwargs.as_thread;
        delete kwargs.as_thread;
        only_id = kwargs.only_id;
        delete kwargs.only_id;
        if (records && !(records instanceof models.Model)) {
            throw new Error(`expected recordset for many: ${records}`);
        }
        return [MANY, records, mode, as_thread, only_id, makeKwArgs(kwargs)];
    }

    static one(records, as_thread, only_id, kwargs) {
        kwargs = getKwArgs(arguments, "records");
        records = kwargs.records;
        delete kwargs.records;
        as_thread = kwargs.as_thread;
        delete kwargs.as_thread;
        only_id = kwargs.only_id;
        delete kwargs.only_id;
        if (records && !(records instanceof models.Model)) {
            throw new Error(`expected recordset for one: ${records}`);
        }
        return [ONE, records, as_thread, only_id, makeKwArgs(kwargs)];
    }

    static many_ids(records, mode = "REPLACE", as_thread) {
        const kwargs = getKwArgs(arguments, "records", "mode");
        records = kwargs.records;
        mode = kwargs.mode ?? "REPLACE";
        as_thread = kwargs.as_thread;
        if (records && !(records instanceof models.Model)) {
            throw new Error(`expected recordset for many_ids: ${records}`);
        }
        if (!["ADD", "DELETE", "REPLACE"].includes(mode)) {
            throw new Error(`invalid mode for many_ids: ${mode} `);
        }
        let res = records.map((record) =>
            Store.one_id(records.browse(record.id), makeKwArgs({ as_thread }))
        );
        if (records._name === "mail.message.reaction") {
            res = [];
            const reactionGroups = groupBy(records, (r) => [r.message_id, r.content]);
            for (const groupId in reactionGroups) {
                const { message_id, content } = reactionGroups[groupId][0];
                res.push({ message: message_id, content: content });
            }
        }
        if (mode === "ADD") {
            res = [["ADD", res]];
        } else if (mode === "DELETE") {
            res = [["DELETE", res]];
        }
        return res;
    }

    static one_id(records, as_thread) {
        const kwargs = getKwArgs(arguments, "records");
        records = kwargs.records;
        as_thread = kwargs.as_thread;
        if (!records) {
            return false;
        }
        if (!(records instanceof models.Model)) {
            throw new Error(`expected recordset for one_id: ${records}`);
        }
        if (records.length > 1) {
            throw new Error(`expected none or single record for one_id: ${records}`);
        }
        const [record] = records;
        if (!record) {
            return false;
        }
        if (as_thread) {
            return { id: record.id, model: records._name };
        }
        if (records._name === "discuss.channel") {
            return { id: record.id, model: "discuss.channel" };
        }
        if (records._name === "mail.guest") {
            return { id: record.id, type: "guest" };
        }
        if (records._name === "res.partner") {
            return { id: record.id, type: "partner" };
        }
        return record.id;
    }
}

export const mailDataHelpers = {
    processRequest,
    Store,
};
