/* @odoo-module */

import { BaseStore, Record, makeStore, modelRegistry } from "@mail/core/common/record";

import { registry } from "@web/core/registry";
import { clearRegistryWithCleanup, makeTestEnv } from "@web/../tests/helpers/mock_env";
import { assertSteps, step } from "@web/../tests/utils";
import { markup, reactive, toRaw } from "@odoo/owl";

const serviceRegistry = registry.category("services");

let start;
QUnit.module("record", {
    beforeEach() {
        serviceRegistry.add("store", { start: (env) => makeStore(env) });
        clearRegistryWithCleanup(modelRegistry);
        Record.register();
        ({ Store: class extends BaseStore {} }).Store.register();
        start = async () => {
            const env = await makeTestEnv();
            return env.services.store;
        };
    },
});

QUnit.test("Insert by passing only single-id value (non-relational)", async (assert) => {
    (class Persona extends Record {
        static id = "name";
        name;
    }).register();
    const store = await start();
    const john = store.Persona.insert("John");
    assert.strictEqual(john.name, "John");
});

QUnit.test("Can pass object as data for relational field with inverse as id", async (assert) => {
    (class Thread extends Record {
        static id = "name";
        name;
        composer = Record.one("Composer", { inverse: "thread" });
    }).register();
    (class Composer extends Record {
        static id = "thread";
        thread = Record.one("Thread");
    }).register();
    const store = await start();
    const thread = store.Thread.insert("General");
    Object.assign(thread, { composer: {} });
    assert.ok(thread.composer);
    assert.ok(thread.composer.thread.eq(thread));
});

QUnit.test("Assign & Delete on fields with inverses", async (assert) => {
    (class Thread extends Record {
        static id = "name";
        name;
        composer = Record.one("Composer", { inverse: "thread" });
        members = Record.many("Member", { inverse: "thread" });
        messages = Record.many("Message", { inverse: "threads" });
    }).register();
    (class Composer extends Record {
        static id = "thread";
        thread = Record.one("Thread");
    }).register();
    (class Member extends Record {
        static id = "name";
        name;
        thread = Record.one("Thread");
    }).register();
    (class Message extends Record {
        static id = "content";
        content;
        threads = Record.many("Thread");
    }).register();
    const store = await start();
    const thread = store.Thread.insert("General");
    const [john, marc] = store.Member.insert(["John", "Marc"]);
    const [hello, world] = store.Message.insert(["hello", "world"]);
    // Assign on fields should adapt inverses
    Object.assign(thread, { composer: {}, members: [["ADD", john]], messages: [hello, world] });
    assert.ok(thread.composer);
    assert.ok(thread.composer.thread.eq(thread));
    assert.ok(john.thread.eq(thread));
    assert.ok(john.in(thread.members));
    assert.ok(hello.in(thread.messages));
    assert.ok(world.in(thread.messages));
    assert.ok(thread.in(hello.threads));
    assert.ok(thread.in(world.threads));
    // add() should adapt inverses
    thread.members.add(marc);
    assert.ok(marc.in(thread.members));
    assert.ok(marc.thread.eq(thread));
    // delete should adapt inverses
    thread.members.delete(john);
    assert.notOk(john.in(thread.members));
    assert.notOk(john.thread);
    // can delete with command
    thread.messages = [["DELETE", world]];
    assert.notOk(world.in(thread.messages));
    assert.notOk(thread.in(world.threads));
    assert.ok(thread.messages.length === 1);
    assert.ok(hello.in(thread.messages));
    assert.ok(thread.in(hello.threads));
    // Deletion removes all relations
    const composer = thread.composer;
    thread.delete();
    assert.notOk(thread.composer);
    assert.notOk(composer.thread);
    assert.notOk(marc.in(thread.members));
    assert.ok(thread.members.length === 0);
    assert.notOk(hello.in(thread.messages));
    assert.notOk(thread.in(hello.threads));
    assert.ok(thread.messages.length === 0);
});

QUnit.test("onAdd/onDelete hooks on relational with inverse", async (assert) => {
    let logs = [];
    (class Thread extends Record {
        static id = "name";
        name;
        members = Record.many("Member", {
            inverse: "thread",
            onAdd: (member) => logs.push(`Thread.onAdd(${member.name})`),
            onDelete: (member) => logs.push(`Thread.onDelete(${member.name})`),
        });
    }).register();
    (class Member extends Record {
        static id = "name";
        name;
        thread = Record.one("Thread");
    }).register();
    const store = await start();
    const thread = store.Thread.insert("General");
    const [john, marc] = store.Member.insert(["John", "Marc"]);
    thread.members.add(john);
    assert.deepEqual(logs, ["Thread.onAdd(John)"]);
    logs = [];
    thread.members.add(john);
    assert.deepEqual(logs, []);
    marc.thread = thread;
    assert.deepEqual(logs, ["Thread.onAdd(Marc)"]);
    logs = [];
    thread.members.delete(marc);
    assert.deepEqual(logs, ["Thread.onDelete(Marc)"]);
    logs = [];
    thread.members.delete(marc);
    assert.deepEqual(logs, []);
    john.thread = undefined;
    assert.deepEqual(logs, ["Thread.onDelete(John)"]);
});

QUnit.test("Computed fields", async (assert) => {
    (class Thread extends Record {
        static id = "name";
        name;
        type = Record.attr("", {
            compute() {
                if (this.members.length === 0) {
                    return "empty chat";
                } else if (this.members.length === 1) {
                    return "self-chat";
                } else if (this.members.length === 2) {
                    return "dm chat";
                } else {
                    return "group chat";
                }
            },
        });
        admin = Record.one("Persona", {
            compute() {
                return this.members[0];
            },
        });
        members = Record.many("Persona");
    }).register();
    (class Persona extends Record {
        static id = "name";
        name;
    }).register();
    const store = await start();
    const thread = store.Thread.insert("General");
    const [john, marc, antony] = store.Persona.insert(["John", "Marc", "Antony"]);
    Object.assign(thread, { members: [john, marc] });
    assert.ok(thread.admin.eq(john));
    assert.strictEqual(thread.type, "dm chat");
    thread.members.delete(john);
    assert.ok(thread.admin.eq(marc));
    assert.strictEqual(thread.type, "self-chat");
    thread.members.unshift(antony, john);
    assert.ok(thread.admin.eq(antony));
    assert.strictEqual(thread.type, "group chat");
});

QUnit.test("Computed fields: lazy (default) vs. eager", async (assert) => {
    (class Thread extends Record {
        static id = "name";
        name;
        computeType() {
            if (this.members.length === 0) {
                return "empty chat";
            } else if (this.members.length === 1) {
                return "self-chat";
            } else if (this.members.length === 2) {
                return "dm chat";
            } else {
                return "group chat";
            }
        }
        typeLazy = Record.attr("", {
            compute() {
                assert.step("LAZY");
                return this.computeType();
            },
        });
        typeEager = Record.attr("", {
            compute() {
                assert.step("EAGER");
                return this.computeType();
            },
            eager: true,
        });
        members = Record.many("Persona");
    }).register();
    (class Persona extends Record {
        static id = "name";
        name;
    }).register();
    const store = await start();
    const thread = store.Thread.insert("General");
    const members = thread.members;
    assert.verifySteps(["EAGER"]);
    assert.strictEqual(thread.typeEager, "empty chat");
    assert.verifySteps([]);
    assert.strictEqual(thread.typeLazy, "empty chat");
    assert.verifySteps(["LAZY"]);
    members.add("John");
    assert.verifySteps(["EAGER"]);
    assert.strictEqual(thread.typeEager, "self-chat");
    assert.verifySteps([]);
    members.add("Antony");
    assert.verifySteps(["EAGER"]);
    assert.strictEqual(thread.typeEager, "dm chat");
    assert.verifySteps([]);
    members.add("Demo");
    assert.verifySteps(["EAGER"]);
    assert.strictEqual(thread.typeEager, "group chat");
    assert.strictEqual(thread.typeLazy, "group chat");
    assert.verifySteps(["LAZY"]);
});

QUnit.test("Trusted insert on html field with { html: true }", async (assert) => {
    (class Message extends Record {
        static id = "body";
        body = Record.attr("", { html: true });
    }).register();
    const store = await start();
    const hello = store.Message.insert("<p>hello</p>", { html: true });
    const world = store.Message.insert("<p>world</p>");
    assert.ok(hello.body instanceof markup("").constructor);
    assert.strictEqual(hello.body.toString(), "<p>hello</p>");
    assert.strictEqual(world.body, "<p>world</p>");
});

QUnit.test("(Un)trusted insertion is applied even with same field value", async (assert) => {
    (class Message extends Record {
        static id = "id";
        id;
        body = Record.attr("", { html: true });
    }).register();
    const store = await start();
    const rawMessage = { id: 1, body: "<p>hello</p>" };
    let message = store.Message.insert(rawMessage);
    assert.notOk(message.body instanceof markup("").constructor);
    message = store.Message.insert(rawMessage, { html: true });
    assert.ok(message.body instanceof markup("").constructor);
    message = store.Message.insert(rawMessage);
    assert.notOk(message.body instanceof markup("").constructor);
});

QUnit.test("Unshift preserves order", async (assert) => {
    (class Message extends Record {
        static id = "id";
        id;
    }).register();
    (class Thread extends Record {
        static id = "name";
        name;
        messages = Record.many("Message");
    }).register();
    const store = await start();
    const thread = store.Thread.insert({ name: "General" });
    thread.messages.unshift({ id: 3 }, { id: 2 }, { id: 1 });
    assert.deepEqual(
        thread.messages.map((msg) => msg.id),
        [3, 2, 1]
    );
    thread.messages.unshift({ id: 6 }, { id: 5 }, { id: 4 });
    assert.deepEqual(
        thread.messages.map((msg) => msg.id),
        [6, 5, 4, 3, 2, 1]
    );
    thread.messages.unshift({ id: 7 });
    assert.deepEqual(
        thread.messages.map((msg) => msg.id),
        [7, 6, 5, 4, 3, 2, 1]
    );
});

QUnit.test("onAdd hook should see fully inserted data", async (assert) => {
    (class Thread extends Record {
        static id = "name";
        name;
        members = Record.many("Member", {
            inverse: "thread",
            onAdd: (member) =>
                assert.step(`Thread.onAdd::${member.name}.${member.type}.${member.isAdmin}`),
        });
    }).register();
    (class Member extends Record {
        static id = "name";
        name;
        type;
        isAdmin = Record.attr(false, {
            compute() {
                return this.type === "admin";
            },
        });
        thread = Record.one("Thread");
    }).register();
    const store = await start();
    const thread = store.Thread.insert("General");
    thread.members.add({ name: "John", type: "admin" });
    assert.verifySteps(["Thread.onAdd::John.admin.true"]);
});

QUnit.test("Can insert with relation as id, using relation as data object", async (assert) => {
    (class User extends Record {
        static id = "name";
        name;
        settings = Record.one("Settings");
    }).register();
    (class Settings extends Record {
        static id = "user";
        pushNotif;
        user = Record.one("User", { inverse: "settings" });
    }).register();
    const store = await start();
    store.Settings.insert([
        { pushNotif: true, user: { name: "John" } },
        { pushNotif: false, user: { name: "Paul" } },
    ]);
    assert.ok(store.User.get("John"));
    assert.ok(store.User.get("John").settings.pushNotif);
    assert.ok(store.User.get("Paul"));
    assert.notOk(store.User.get("Paul").settings.pushNotif);
});

QUnit.test("Set on attr should invoke onChange", async (assert) => {
    (class Message extends Record {
        static id = "id";
        id;
        body;
    }).register();
    const store = await start();
    const message = store.Message.insert(1);
    Record.onChange(message, "body", () => assert.step("BODY_CHANGED"));
    assert.verifySteps([]);
    message.update({ body: "test1" });
    message.body = "test2";
    assert.verifySteps(["BODY_CHANGED", "BODY_CHANGED"]);
});

QUnit.test("record list sort should be manually observable", async (assert) => {
    (class Thread extends Record {
        static id = "id";
        id;
        messages = Record.many("Message", { inverse: "thread" });
    }).register();
    (class Message extends Record {
        static id = "id";
        id;
        body;
        author;
        thread = Record.one("Thread", { inverse: "messages" });
    }).register();
    const store = await start();
    const thread = store.Thread.insert(1);
    const messages = store.Message.insert([
        { id: 1, body: "a", thread },
        { id: 2, body: "b", thread },
    ]);
    function sortMessages() {
        // minimal access through observed variables to reduce unexpected observing
        observedMessages.sort((m1, m2) => (m1.body < m2.body ? -1 : 1));
        assert.step(`sortMessages`);
    }
    const observedMessages = reactive(thread.messages, sortMessages);
    assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
    sortMessages();
    assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
    assert.verifySteps(["sortMessages"]);
    messages[0].body = "c";
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
    assert.verifySteps(["sortMessages", "sortMessages"]);
    messages[0].body = "d";
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
    assert.verifySteps(["sortMessages"]);
    messages[0].author = "Jane";
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
    assert.verifySteps([]);
    store.Message.insert({ id: 3, body: "c", thread });
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,3,1");
    assert.verifySteps(["sortMessages", "sortMessages"]);
    messages[0].delete();
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,3");
    assert.verifySteps(["sortMessages"]);
});

QUnit.test("relation field sort should be automatically observed", async (assert) => {
    (class Thread extends Record {
        static id = "id";
        id;
        messages = Record.many("Message", {
            inverse: "thread",
            sort: (m1, m2) => (m1.body < m2.body ? -1 : 1),
        });
    }).register();
    (class Message extends Record {
        static id = "id";
        id;
        body;
        author;
        thread = Record.one("Thread", { inverse: "messages" });
    }).register();
    const store = await start();
    const thread = store.Thread.insert(1);
    const messages = store.Message.insert([
        { id: 1, body: "a", thread },
        { id: 2, body: "b", thread },
    ]);
    assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
    messages[0].body = "c";
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
    messages[0].body = "d";
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
    messages[0].author = "Jane";
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,1");
    store.Message.insert({ id: 3, body: "c", thread });
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,3,1");
    messages[0].delete();
    assert.equal(`${thread.messages.map((m) => m.id)}`, "2,3");
});

QUnit.test("reading of lazy compute relation field should recompute", async (assert) => {
    (class Thread extends Record {
        static id = "id";
        id;
        messages = Record.many("Message", {
            inverse: "thread",
            sort: (m1, m2) => (m1.body < m2.body ? -1 : 1),
        });
        messages2 = Record.many("Message", {
            compute() {
                return this.messages.map((m) => m.id);
            },
        });
    }).register();
    (class Message extends Record {
        static id = "id";
        id;
        thread = Record.one("Thread", { inverse: "messages" });
    }).register();
    const store = await start();
    const thread = store.Thread.insert(1);
    store.Message.insert([
        { id: 1, thread },
        { id: 2, thread },
    ]);
    const messages2 = thread.messages2;
    assert.strictEqual(`${messages2.map((m) => m.id)}`, "1,2");
    store.Message.insert([{ id: 3, thread }]);
    assert.strictEqual(`${messages2.map((m) => m.id)}`, "1,2,3");
    store.Message.insert([{ id: 4, thread }]);
    assert.strictEqual(`${messages2.map((m) => m.id)}`, "1,2,3,4");
});

QUnit.test("lazy compute should re-compute while they are observed", async (assert) => {
    (class Channel extends Record {
        static id = "id";
        id;
        count = 0;
        multiplicity = Record.attr(undefined, {
            compute() {
                assert.step("computing");
                if (this.count > 3) {
                    return "many";
                }
                return "few";
            },
        });
    }).register();
    const store = await start();
    const channel = store.Channel.insert(1);
    let observe = true;
    function render() {
        if (observe) {
            assert.step(`render ${reactiveChannel.multiplicity}`);
        }
    }
    const reactiveChannel = reactive(channel, render);
    render();
    assert.verifySteps(
        ["computing", "render few", "render few"],
        "initial call, render with new value"
    );
    channel.count = 2;
    assert.verifySteps(["computing"], "changing value to 2 is observed");
    channel.count = 5;
    assert.verifySteps(["computing", "render many"], "changing value to 5 is observed");
    observe = false;
    channel.count = 6;
    assert.verifySteps(["computing"], "changing value to 6, still observed until it changes");
    channel.count = 7;
    assert.verifySteps(["computing"], "changing value to 7, still observed until it changes");
    channel.count = 1;
    assert.verifySteps(["computing"], "changing value to 1, observed one last time");
    channel.count = 0;
    assert.verifySteps([], "changing value to 0, no longer observed");
    channel.count = 7;
    assert.verifySteps([], "changing value to 7, no longer observed");
    channel.count = 1;
    assert.verifySteps([], "changing value to 1, no longer observed");
    assert.strictEqual(channel.multiplicity, "few");
    assert.verifySteps(["computing"]);
    observe = true;
    render();
    assert.verifySteps(["render few"]);
    channel.count = 7;
    assert.verifySteps(["computing", "render many"]);
});

QUnit.test("lazy sort should re-sort while they are observed", async (assert) => {
    (class Thread extends Record {
        static id = "id";
        id;
        messages = Record.many("Message", {
            sort: (m1, m2) => m1.sequence - m2.sequence,
        });
    }).register();
    (class Message extends Record {
        static id = "id";
        id;
        sequence;
    }).register();
    const store = await start();
    const thread = store.Thread.insert(1);
    thread.messages.push({ id: 1, sequence: 1 }, { id: 2, sequence: 2 });
    assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
    let observe = true;
    function render() {
        if (observe) {
            assert.step(`render ${reactiveChannel.messages.map((m) => m.id)}`);
        }
    }
    const reactiveChannel = reactive(thread, render);
    render();
    const message = thread.messages[0];
    assert.verifySteps(["render 1,2"]);
    message.sequence = 3;
    assert.verifySteps(["render 2,1"]);
    message.sequence = 4;
    assert.verifySteps([]);
    message.sequence = 5;
    assert.verifySteps([]);
    message.sequence = 1;
    assert.verifySteps(["render 1,2"]);
    observe = false;
    message.sequence = 10;
    assert.equal(
        `${toRaw(thread)
            ._raw._fields.get("messages")
            .value.data.map((localId) => toRaw(thread)._raw._store.get(localId).id)}`,
        "2,1",
        "observed one last time when it changes"
    );
    assert.verifySteps([]);
    message.sequence = 1;
    assert.equal(
        `${toRaw(thread)
            ._raw._fields.get("messages")
            .value.data.map((localId) => toRaw(thread)._raw._store.get(localId).id)}`,
        "2,1",
        "no longer observed"
    );
    assert.equal(`${thread.messages.map((m) => m.id)}`, "1,2");
    observe = true;
    render();
    assert.verifySteps(["render 1,2"]);
    message.sequence = 10;
    assert.verifySteps(["render 2,1"]);
});

QUnit.test("store updates can be observed", async (assert) => {
    const store = await start();
    function onUpdate() {
        assert.step(`abc:${reactiveStore.abc}`);
    }
    const rawStore = toRaw(store)._raw;
    const reactiveStore = reactive(store, onUpdate);
    onUpdate();
    assert.verifySteps(["abc:undefined"]);
    store.abc = 1;
    assert.verifySteps(["abc:1"], "observable from makeStore");
    rawStore._store.abc = 2;
    assert.verifySteps(["abc:2"], "observable from record._store");
    rawStore.Model.store.abc = 3;
    assert.verifySteps(["abc:3"], "observable from Model.store");
});

QUnit.test("onAdd/onDelete hooks on one without inverse", async () => {
    (class Thread extends Record {
        static id = "name";
    }).register();
    (class Member extends Record {
        static id = "name";
        name;
        thread = Record.one("Thread", {
            onAdd: (thread) => step(`thread.onAdd(${thread.name})`),
            onDelete: (thread) => step(`thread.onDelete(${thread.name})`),
        });
    }).register();
    const store = await start();
    const general = store.Thread.insert("General");
    const john = store.Member.insert("John");
    await assertSteps([]);
    john.thread = general;
    await assertSteps(["thread.onAdd(General)"]);
    john.thread = general;
    await assertSteps([]);
    john.thread = undefined;
    await assertSteps(["thread.onDelete(General)"]);
});

QUnit.test("onAdd/onDelete hooks on many without inverse", async () => {
    (class Thread extends Record {
        static id = "name";
        name;
        members = Record.many("Member", {
            onAdd: (member) => step(`members.onAdd(${member.name})`),
            onDelete: (member) => step(`members.onDelete(${member.name})`),
        });
    }).register();
    (class Member extends Record {
        static id = "name";
    }).register();
    const store = await start();
    const general = store.Thread.insert("General");
    const jane = store.Member.insert("Jane");
    const john = store.Member.insert("John");
    await assertSteps([]);
    general.members = jane;
    await assertSteps(["members.onAdd(Jane)"]);
    general.members = jane;
    await assertSteps([]);
    general.members = [["ADD", john]];
    await assertSteps(["members.onAdd(John)"]);
    general.members = undefined;
    await assertSteps(["members.onDelete(John)", "members.onDelete(Jane)"]);
});

QUnit.test("record list assign should update inverse fields", async (assert) => {
    (class Thread extends Record {
        static id = "name";
        name;
        members = Record.many("Member", { inverse: "thread" });
    }).register();
    (class Member extends Record {
        static id = "name";
        thread = Record.one("Thread", { inverse: "members" });
    }).register();
    const store = await start();
    const general = store.Thread.insert("General");
    const jane = store.Member.insert("Jane");
    general.members = jane; // direct assignation of value goes through assign()
    assert.ok(jane.thread.eq(general));
    general.members = []; // writing empty array specifically goes through assign()
    assert.notOk(jane.thread);
    jane.thread = general;
    assert.ok(jane.in(general.members));
    jane.thread = [];
    assert.ok(jane.notIn(general.members));
});
