/** @odoo-module alias=@bus/../tests/helpers/mock_python_environment default=false */

import { TEST_USER_IDS } from "@bus/../tests/helpers/test_constants";

import { registry } from "@web/core/registry";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { makeMockServer } from "@web/../tests/helpers/mock_server";
import { patchUserWithCleanup } from "@web/../tests/helpers/mock_services";
import { serializeDateTime, serializeDate } from "@web/core/l10n/dates";

const { DateTime } = luxon;

const modelDefinitionsPromise = new Promise((resolve) => {
    QUnit.begin(() => resolve(getModelDefinitions()));
});

/**
 * Fetch model definitions from the server then insert fields present in the
 * `bus.model.definitions` registry. Use `addModelNamesToFetch`/`insertModelFields`
 * helpers in order to add models to be fetched, default values to the fields,
 * fields to a model definition.
 *
 * @return {Map<string, Object>} A map from model names to model fields definitions.
 * @see model_definitions_setup.js
 */
async function getModelDefinitions() {
    const modelDefinitionsRegistry = registry.category("bus.model.definitions");
    const modelNamesToFetch = modelDefinitionsRegistry.get("modelNamesToFetch");
    const fieldsToInsertRegistry = modelDefinitionsRegistry.category("fieldsToInsert");

    // fetch the model definitions.
    const formData = new FormData();
    formData.append("csrf_token", odoo.csrf_token);
    formData.append("model_names_to_fetch", JSON.stringify(modelNamesToFetch));
    const response = await window.fetch("/bus/get_model_definitions", {
        body: formData,
        method: "POST",
    });
    if (response.status !== 200) {
        throw new Error("Error while fetching required models");
    }
    const modelDefinitions = new Map(Object.entries(await response.json()));

    for (const [modelName, modelDefinition] of modelDefinitions) {
        // insert fields present in the fieldsToInsert registry : if the field
        // exists, update its default value according to the one in the
        // registry; If it does not exist, add it to the model definition.
        const fieldNamesToFieldToInsert = fieldsToInsertRegistry.category(modelName).getEntries();
        const fields = modelDefinition.fields;
        for (const [fname, fieldToInsert] of fieldNamesToFieldToInsert) {
            if (fname in fields) {
                fields[fname].default = fieldToInsert.default;
            } else {
                fields[fname] = fieldToInsert;
            }
        }
        // apply default values for date like fields if none was passed.
        for (const fname in fields) {
            const field = fields[fname];
            if (["date", "datetime"].includes(field.type) && !field.default) {
                const defaultFieldValue =
                    field.type === "date"
                        ? () => serializeDate(DateTime.utc())
                        : () => serializeDateTime(DateTime.utc());
                field.default = defaultFieldValue;
            } else if (fname === "active" && !("default" in field)) {
                // records are active by default.
                field.default = true;
            }
        }
    }
    // add models present in the fake models registry to the model definitions.
    const fakeModels = modelDefinitionsRegistry.category("fakeModels").getEntries();
    for (const [modelName, fields] of fakeModels) {
        const modelDefinition = {};
        modelDefinition.fields = fields;
        modelDefinitions.set(modelName, modelDefinition);
    }
    return modelDefinitions;
}

let _cookie = {};
export const pyEnvTarget = {
    cookie: {
        get(key) {
            return _cookie[key];
        },
        set(key, value) {
            _cookie[key] = value;
        },
        delete(key) {
            delete _cookie[key];
        },
    },
    _authenticate(user) {
        if (!user) {
            throw new Error("Unauthorized");
        }
        this.cookie.set("sid", user.id);
        const userSession = {
            name: user.name,
            partnerId: user.partner_id,
            userId: user.id,
        };
        patchUserWithCleanup(userSession);
    },
    /**
     * Authenticate a user on the mock server given its login
     * and password.
     *
     * @param {string} login
     * @param {string|} password
     */
    authenticate(login, password) {
        const user = this.mockServer.getRecords(
            "res.users",
            [
                ["login", "=", login],
                ["password", "=", password],
            ],
            { active_test: false }
        )[0];
        this._authenticate(user);
        this.cookie.set("authenticated_user_sid", this.cookie.get("sid"));
    },
    /**
     * Logout the current user.
     */
    logout() {
        if (this.cookie.get("authenticated_user_sid") === this.cookie.get("sid")) {
            this.cookie.delete("authenticated_user_sid");
        }
        this.cookie.delete("sid");
        const [publicUser] = this.mockServer.getRecords(
            "res.users",
            [["id", "=", this.publicUserId]],
            { active_test: false }
        );
        this.authenticate(publicUser.login, publicUser.password);
    },
    /**
     * Execute the provided function with the given user
     * authenticated then restore the original user.
     *
     * @param {number} userId
     * @param {Function} fn
     */
    async withUser(userId, fn) {
        const user = this.currentUser;
        const targetUser = this.mockServer.getRecords("res.users", [["id", "=", userId]], {
            active_test: false,
        })[0];
        this._authenticate(targetUser);
        let result;
        try {
            result = await fn();
        } finally {
            if (user) {
                this._authenticate(user);
            } else {
                this.logout();
            }
        }
        return result;
    },
    /**
     * The current user, either the one authenticated or the one
     * impersonated by `withUser`.
     */
    get currentUser() {
        let user;
        const currentUserId = this.cookie.get("sid");
        if ("res.users" in this.mockServer.models && currentUserId) {
            user = this.mockServer.getRecords("res.users", [["id", "=", currentUserId]], {
                active_test: false,
            })[0];
            user = user ? { ...user, _is_public: () => user.id === this.publicUserId } : undefined;
        }
        return user;
    },
    /**
     * The current partner, either the one of the current user or
     * the one of the user impersonated by `withUser`.
     */
    get currentPartner() {
        if ("res.partner" in this.mockServer.models && this.currentUser?.partner_id) {
            return this.mockServer.getRecords(
                "res.partner",
                [["id", "=", this.currentUser?.partner_id]],
                { active_test: false }
            )[0];
        }
        return undefined;
    },
    get currentUserId() {
        return this.currentUser?.id;
    },
    get currentPartnerId() {
        return this.currentPartner?.id;
    },
    getData() {
        return this.mockServer.models;
    },
    getViews() {
        return this.mockServer.archs;
    },
    simulateConnectionLost(closeCode) {
        this.mockServer._simulateConnectionLost(closeCode);
    },
    ...TEST_USER_IDS,
};

let pyEnv;
/**
 * Creates an environment that can be used to setup test data as well as
 * creating data after test start.
 *
 * @param {Object} serverData serverData to pass to the mockServer.
 * @param {Object} [serverData.action] actions to be passed to the mock
 * server.
 * @param {Object} [serverData.views] views to be passed to the mock
 * server.
 * @returns {Object} An environment that can be used to interact with
 * the mock server (creation, deletion, update of records...)
 */
export async function startServer({ actions, views = {} } = {}) {
    const models = {};
    const modelDefinitions = await modelDefinitionsPromise;
    const recordsToInsertRegistry = registry
        .category("bus.model.definitions")
        .category("recordsToInsert");
    for (const [modelName, modelDefinition] of modelDefinitions) {
        const records = [];
        if (recordsToInsertRegistry.contains(modelName)) {
            // prevent tests from mutating the records.
            records.push(...JSON.parse(JSON.stringify(recordsToInsertRegistry.get(modelName))));
        }
        models[modelName] = { ...modelDefinition, records };

        // generate default views for this model if none were passed.
        const viewArchsSubRegistries = registry.category("bus.view.archs").subRegistries;
        for (const [viewType, archsRegistry] of Object.entries(viewArchsSubRegistries)) {
            views[`${modelName},false,${viewType}`] =
                views[`${modelName},false,${viewType}`] ||
                archsRegistry.get(modelName, archsRegistry.get("default"));
        }
    }
    pyEnv = new Proxy(pyEnvTarget, {
        get(target, name) {
            if (name in target) {
                return target[name];
            }
            const modelAPI = {
                /**
                 * Simulate a 'create' operation on a model.
                 *
                 * @param {Object[]|Object} values records to be created.
                 * @returns {integer[]|integer} array of ids if more than one value was passed,
                 * id of created record otherwise.
                 */
                create(values) {
                    if (!values) {
                        return;
                    }
                    if (!Array.isArray(values)) {
                        values = [values];
                    }
                    const recordIds = values.map((value) =>
                        target.mockServer.mockCreate(name, value)
                    );
                    return recordIds.length === 1 ? recordIds[0] : recordIds;
                },
                /**
                 * Simulate a 'search' operation on a model.
                 *
                 * @param {Array} domain
                 * @param {Object} context
                 * @returns {integer[]} array of ids corresponding to the given domain.
                 */
                search(domain, context = {}) {
                    return target.mockServer.mockSearch(name, [domain], context);
                },
                /**
                 * Simulate a `search_count` operation on a model.
                 *
                 * @param {Array} domain
                 * @return {number} count of records matching the given domain.
                 */
                searchCount(domain) {
                    return this.search(domain).length;
                },
                /**
                 * Simulate a 'search_read' operation on a model.
                 *
                 * @param {Array} domain
                 * @param {Object} kwargs
                 * @returns {Object[]} array of records corresponding to the given domain.
                 */
                search_read(domain, kwargs = {}) {
                    return target.mockServer.mockSearchRead(name, [domain], kwargs);
                },
                /**
                 * Simulate an 'unlink' operation on a model.
                 *
                 * @param {integer[]} ids
                 * @returns {boolean} mockServer 'unlink' method always returns true.
                 */
                unlink(ids) {
                    return target.mockServer.mockUnlink(name, [ids]);
                },
                /**
                 * Simulate a 'write' operation on a model.
                 *
                 * @param {integer[]} ids ids of records to write on.
                 * @param {Object} values values to write on the records matching given ids.
                 * @returns {boolean} mockServer 'write' method always returns true.
                 */
                write(ids, values) {
                    return target.mockServer.mockWrite(name, [ids, values]);
                },
            };
            if (name === "bus.bus") {
                modelAPI["_sendone"] = target.mockServer._mockBusBus__sendone.bind(
                    target.mockServer
                );
                modelAPI["_sendmany"] = target.mockServer._mockBusBus__sendmany.bind(
                    target.mockServer
                );
            }
            return modelAPI;
        },
    });
    pyEnv["mockServer"] = await makeMockServer({ actions, models, views });
    pyEnv["mockServer"].pyEnv = pyEnv;
    registerCleanup(() => {
        pyEnv = undefined;
        _cookie = {};
    });
    if ("res.users" in pyEnv.mockServer.models) {
        const adminUser = pyEnv["res.users"].search_read([["id", "=", pyEnv.adminUserId]])[0];
        pyEnv.authenticate(adminUser.login, adminUser.password);
    }
    return pyEnv;
}

/**
 *
 * @returns {Object} An environment that can be used to interact with the mock
 * server (creation, deletion, update of records...)
 */
export function getPyEnv() {
    return pyEnv || startServer();
}

export const serverState = {
    get partnerId() {
        return pyEnv?.currentPartnerId;
    },
    get userId() {
        return pyEnv?.currentUserId;
    },
};
