import { helper } from "@grouparoo/spec-helper";
import { specHelper, config } from "actionhero";
import { GrouparooRecord, Group, Run, Import, GroupMember } from "../../../src";
import { SharedGroupTests } from "../../utils/prepareSharedGroupTest";
import { GroupOps } from "../../../src/modules/ops/group";
import { GroupRuleWithKey } from "../../../src/models/Group";

describe("models/group", () => {
  helper.grouparooTestServer({ truncate: true, enableTestPlugin: true });

  describe("groups calculations", () => {
    let run: Run;
    let group: Group;
    let mario: GrouparooRecord;
    let luigi: GrouparooRecord;
    let peach: GrouparooRecord;
    let toad: GrouparooRecord;

    beforeAll(async () => {
      const response = await SharedGroupTests.beforeAll();
      mario = response.mario;
      luigi = response.luigi;
      peach = response.peach;
      toad = response.toad;
    });

    beforeEach(async () => {
      const response = await SharedGroupTests.beforeEach();
      group = response.group;
      run = await helper.factories.run();
    });

    afterEach(async () => {
      await SharedGroupTests.afterEach();
      await run.destroy();
      process.env.GROUPAROO_RUN_MODE = undefined;
    });

    test("an empty group can be created", async () => {
      const members = await group.$get("groupMembers");
      expect(members.length).toBe(0);
      expect(group.state).toBe("draft");
    });

    test("setting rules outside of the dictionary will fail", async () => {
      await expect(
        group.setRules([
          //@ts-ignore We are checking something explicitly not in the type
          { key: "firstName", match: "nobody", operation: { op: "wacky" } },
        ])
      ).rejects.toThrow(
        /invalid group rule operation "wacky" for property of type string/
      );
    });

    if (config.sequelize.dialect !== "sqlite") {
      test("setting rules that fail a SQL comparison", async () => {
        await expect(
          group.setRules([
            { key: "ltv", match: "fish", operation: { op: "gt" } },
          ])
        ).rejects.toThrow(/fish/); // the error message is dependant on the database, but should contain the column name
      });
    }

    test("changing group rules changes the state to initializing and enquires a run, and then back to ready when complete", async () => {
      await group.setRules([
        { key: "firstName", match: "nobody", operation: { op: "eq" } },
      ]);
      expect(group.state).toBe("updating");

      const run = await Run.findOne({
        where: { state: "running", creatorId: group.id },
      });

      await specHelper.runTask("group:run", { runId: run.id }); // first run to check additions
      await specHelper.runTask("group:run", { runId: run.id }); // second run to check subtractions
      await specHelper.runTask("group:run", { runId: run.id }); // third run to check old group members
      await specHelper.runTask("group:run", { runId: run.id }); // final run to mark complete

      await run.reload();
      expect(run.state).toBe("complete");
      expect(run.method).toBe("complete");

      await group.reload();
      expect(group.state).toBe("ready");
    });

    test("groups with at least one rule are set to ready in config mode", async () => {
      process.env.GROUPAROO_RUN_MODE = "cli:config";
      expect(group.state).toBe("draft");
      await group.setRules([
        { key: "firstName", match: "nobody", operation: { op: "eq" } },
      ]);
      expect(group.state).toBe("ready");
    });

    test("changing the rules will stop previously running runs", async () => {
      await group.setRules([
        { key: "firstName", match: "nobody", operation: { op: "eq" } },
      ]);
      const firstRun = await Run.findOne({
        where: { creatorId: group.id },
      });
      expect(firstRun.state).toBe("running");

      await group.setRules([
        { key: "lastName", match: "nobody", operation: { op: "eq" } },
      ]);

      await firstRun.reload();
      expect(firstRun.state).toBe("stopped");
    });

    test("we can determine if group rules have been changed", async () => {
      await group.setRules([
        { key: "firstName", match: "Mario", operation: { op: "eq" } },
      ]);
      const rules = await group.getRules();

      expect(GroupOps.rulesAreEqual(rules, [])).toBe(false);
      expect(
        GroupOps.rulesAreEqual(rules, [
          { key: "firstName", match: "Mario", operation: { op: "eq" } },
        ])
      ).toBe(true);
      expect(
        GroupOps.rulesAreEqual(rules, [
          { key: "firstName", match: "Luigi", operation: { op: "eq" } },
        ])
      ).toBe(false);
      expect(
        GroupOps.rulesAreEqual(rules, [
          { key: "LastName", match: "Mario", operation: { op: "eq" } },
        ])
      ).toBe(false);
      expect(
        GroupOps.rulesAreEqual(rules, [
          { key: "firstName", match: "Mario", operation: { op: "ne" } },
        ])
      ).toBe(false);
    });

    describe.each(["any", "all"] as const)(
      'with match type "%s"',
      (matchType) => {
        beforeEach(async () => {
          await group.update({ matchType });
        });

        test("group#runAddGroupMembers will create an import for new members, and touch the updatedAt for existing members", async () => {
          await group.setRules([
            { key: "firstName", match: "Mario", operation: { op: "eq" } },
          ]);
          const response = await group.runAddGroupMembers(run);
          expect(response).toEqual(
            expect.objectContaining({
              groupMembersCount: 1,
              nextOffset: 0,
            })
          );

          // first time
          const imports = await Import.findAll({
            where: { creatorId: run.id },
          });
          expect(imports.length).toBe(1);
          expect(imports[0].recordId).toBe(mario.id);

          // create the groupMember
          await mario.updateGroupMembership();
          const groupMember = await GroupMember.findOne({
            where: { groupId: group.id, recordId: mario.id },
          });
          const firstTime = groupMember.updatedAt.getTime();

          await helper.sleep(1001);

          // second time
          await mario.reload();
          await mario.update({ state: "ready" }); // the import would have made the state 'pending'
          await group.runAddGroupMembers(run);
          await groupMember.reload();
          expect(groupMember.updatedAt.getTime()).toBeGreaterThan(
            groupMember.createdAt.getTime()
          );
          expect(groupMember.updatedAt.getTime()).toBeGreaterThan(firstTime);
        });

        test("group#runAddGroupMembers will include ready records", async () => {
          await group.setRules([
            { key: "firstName", match: "Mario", operation: { op: "eq" } },
          ]);

          await group.runAddGroupMembers(run);

          const imports = await Import.findAll({
            where: { creatorId: run.id },
          });
          expect(imports.length).toBe(1);
          expect(imports[0].recordId).toBe(mario.id);
        });

        test("group#runAddGroupMembers will ignore not-ready records", async () => {
          await group.setRules([
            { key: "firstName", match: "Mario", operation: { op: "eq" } },
          ]);

          await mario.update({ state: "pending" });
          await group.runAddGroupMembers(run);

          const imports = await Import.findAll({
            where: { creatorId: run.id },
          });
          expect(imports.length).toBe(0);
        });

        test("group#runRemoveGroupMembers will create imports for records which should no longer be part of the group and mark removedAt on the group member", async () => {
          await group.setRules([
            { key: "lastName", match: "Mario", operation: { op: "eq" } }, // mario and luigi
          ]);
          const firstAddResponse = await group.runAddGroupMembers(run);
          const firstRemoveResponse = await group.runRemoveGroupMembers(run);
          expect(firstAddResponse).toEqual(
            expect.objectContaining({
              groupMembersCount: 2,
              nextOffset: 0,
            })
          );
          expect(firstRemoveResponse).toEqual(0);

          // create the groupMembers
          await mario.updateGroupMembership();
          await luigi.updateGroupMembership();
          const firstGroupMembers = await GroupMember.findAll({
            where: { groupId: group.id },
          });
          expect(firstGroupMembers.length).toBe(2);

          await mario.reload();
          await luigi.reload();
          await mario.update({ state: "ready" });
          await luigi.update({ state: "ready" });

          // next run
          await group.setRules([
            { key: "firstName", match: "Mario", operation: { op: "eq" } }, // just mario
          ]);
          const nextRun = await helper.factories.run();
          const secondAddResponse = await group.runAddGroupMembers(nextRun);
          const secondRemoveResponse = await group.runRemoveGroupMembers(
            nextRun
          );
          expect(secondAddResponse).toEqual(
            expect.objectContaining({
              groupMembersCount: 1,
              nextOffset: 0,
            })
          );
          expect(secondRemoveResponse).toEqual(1);

          const imports = await Import.findAll({
            where: { creatorId: nextRun.id },
          });
          expect(imports.length).toBe(1);
          expect(imports[0].recordId).toBe(luigi.id);

          const luigiGroupMember = await GroupMember.findOne({
            where: { recordId: luigi.id, groupId: group.id },
          });
          expect(luigiGroupMember.removedAt).toBeTruthy();

          await mario.updateGroupMembership();
          await luigi.updateGroupMembership();

          const secondGroupMembers = await GroupMember.findAll({
            where: { groupId: group.id },
          });
          expect(secondGroupMembers.length).toBe(1);

          await mario.reload();
          await luigi.reload();

          await nextRun.destroy();
        });

        describe("group rules", () => {
          test("it returns 0 members when no rules exist on the group", async () => {
            await group.setRules([]);
            expect(await group.countPotentialMembers()).toBe(0);
          });
        });
      }
    );

    test("runAddGroupMembers updates calculatedAt", async () => {
      expect(group.calculatedAt).toBeFalsy();
      await group.setRules([
        { key: "firstName", match: "Mario", operation: { op: "eq" } },
      ]);
      await group.runAddGroupMembers(run);
      expect(group.calculatedAt).toBeTruthy();
    });

    test("runRemoveGroupMembers updates calculatedAt", async () => {
      expect(group.calculatedAt).toBeFalsy();
      await group.setRules([
        { key: "firstName", match: "Mario", operation: { op: "eq" } },
      ]);
      await group.runRemoveGroupMembers(run);
      expect(group.calculatedAt).toBeTruthy();
    });

    describe("group#nextCalculatedAt", () => {
      test("returns for a group with relative rules ", async () => {
        await group.setRules([
          {
            key: "lastLoginAt",
            operation: { op: "gt" },
            relativeMatchDirection: "subtract",
            relativeMatchNumber: 1,
            relativeMatchUnit: "days",
          },
        ]);

        expect(group.calculatedAt).toBeFalsy();
        expect((await group.nextCalculatedAt()).getTime() / 1000).toBeCloseTo(
          new Date().getTime() / 1000
        );
        await group.runAddGroupMembers(run);
        expect((await group.nextCalculatedAt()).getTime()).toBeGreaterThan(
          new Date().getTime()
        );
      });

      test("does not return for groups without relative rules", async () => {
        await group.setRules([
          {
            key: "ltv",
            operation: { op: "gt" },
            match: "3",
          },
        ]);
        expect(await group.nextCalculatedAt()).toBeNull();
      });
    });

    test("runUpdateMembers with a destination will create imports for all members and include a destinationId in _meta", async () => {
      await group.setRules([
        { key: "lastName", operation: { op: "eq" }, match: "Mario" },
      ]);
      await GroupOps.updateRecords([mario.id], "group", group.id); // make an import
      await GroupOps.updateRecords([luigi.id], "group", group.id); // make an import
      await GroupMember.create({ recordId: mario.id, groupId: group.id });
      await GroupMember.create({ recordId: luigi.id, groupId: group.id });

      await mario.reload();
      await luigi.reload();
      await peach.reload();
      await toad.reload();
      await mario.update({ state: "ready" });
      await luigi.update({ state: "ready" });
      await toad.update({ state: "ready" });
      await peach.update({ state: "ready" });

      let imports = await Import.findAll();
      expect(imports.length).toBe(2);
      await Import.truncate();

      const run = await group.run("abc123");
      await specHelper.runTask("group:run", { runId: run.id });

      imports = await Import.findAll();
      expect(imports.map((i) => i.recordId).sort()).toEqual(
        [mario, luigi].map((p) => p.id).sort()
      );

      const data = { _meta: { destinationId: "abc123" } };
      expect(imports[0].data).toEqual(data);
      expect(imports[1].data).toEqual(data);
    });

    test("group rules must have a related recordProperty Ryle", async () => {
      await expect(
        group.setRules([{ key: "a", match: "cool", operation: { op: "eq" } }])
      ).rejects.toThrow(/cannot find property a/);
    });

    test("recalculating group membership will reuse existing groupMembers", async () => {
      await group.update({ matchType: "all" });
      await group.setRules([
        { key: "lastName", match: "Mario", operation: { op: "eq" } },
        { key: "firstName", match: "Mario", operation: { op: "eq" } },
      ]);
      await group.runAddGroupMembers(run);
      await mario.updateGroupMembership();
      const members = await group.$get("groupMembers");
      expect(members.length).toBe(1);
      const groupMemberId = members[0].id;

      const secondRun = await helper.factories.run();
      await group.runAddGroupMembers(secondRun);
      await mario.updateGroupMembership();
      const membersAgain = await group.$get("groupMembers");
      expect(membersAgain[0].id).toBe(groupMemberId);
      await secondRun.destroy();
    });

    test("calculating a group with more than 10 rules produces an error", async () => {
      group.matchType = "all";
      await expect(
        group.setRules([
          { key: "userId", match: "Mario", operation: { op: "eq" } },
          { key: "lastName", match: "Mario", operation: { op: "eq" } },
          { key: "firstName", match: "Mario", operation: { op: "eq" } },
          { key: "email", match: "Mario", operation: { op: "eq" } },
          { key: "isVIP", match: "Mario", operation: { op: "eq" } },
          { key: "ltv", match: "0", operation: { op: "eq" } },
          { key: "ltv", match: "1", operation: { op: "eq" } },
          { key: "ltv", match: "2", operation: { op: "eq" } },
          { key: "ltv", match: "3", operation: { op: "eq" } },
          { key: "ltv", match: "4", operation: { op: "eq" } },
          { key: "ltv", match: "5", operation: { op: "eq" } },
        ])
      ).rejects.toThrow(/too many group rules/);
    });

    describe("convenientRules", () => {
      describe("fromConvenientRules", () => {
        test("exists", async () => {
          const convenientRules: GroupRuleWithKey[] = [
            { key: "email", operation: { op: "exists" } },
          ];
          const rules = [
            { key: "email", match: "null", operation: { op: "ne" } },
          ];

          expect(group.fromConvenientRules(convenientRules)).toEqual(rules);
        });

        test("notExists", async () => {
          const convenientRules: GroupRuleWithKey[] = [
            { key: "email", operation: { op: "notExists" } },
          ];
          const rules = [
            { key: "email", match: "null", operation: { op: "eq" } },
          ];

          expect(group.fromConvenientRules(convenientRules)).toEqual(rules);
        });

        test("relative_gt", async () => {
          const convenientRules: GroupRuleWithKey[] = [
            {
              key: "lastLoginAt",
              operation: { op: "relative_gt" },
              relativeMatchNumber: 1,
              relativeMatchUnit: "days",
            },
          ];
          const rules = [
            {
              key: "lastLoginAt",
              operation: { op: "gt" },
              relativeMatchDirection: "subtract",
              relativeMatchNumber: 1,
              relativeMatchUnit: "days",
            },
          ];

          expect(group.fromConvenientRules(convenientRules)).toEqual(rules);
        });

        test("relative_lt", async () => {
          const convenientRules: GroupRuleWithKey[] = [
            {
              key: "lastLoginAt",
              operation: { op: "relative_lt" },
              relativeMatchNumber: 1,
              relativeMatchUnit: "days",
            },
          ];
          const rules = [
            {
              key: "lastLoginAt",
              operation: { op: "lt" },
              relativeMatchDirection: "add",
              relativeMatchNumber: 1,
              relativeMatchUnit: "days",
            },
          ];

          expect(group.fromConvenientRules(convenientRules)).toEqual(rules);
        });
      });

      describe("toConvenientRules", () => {
        test("exists", async () => {
          const convenientRules = [
            { type: "email", key: "email", operation: { op: "exists" } },
          ];
          const rules: GroupRuleWithKey[] = [
            {
              type: "email",
              key: "email",
              match: "null",
              operation: { op: "ne" },
            },
          ];

          expect(group.toConvenientRules(rules)).toEqual(convenientRules);
        });

        test("notExists", async () => {
          const convenientRules = [
            { type: "email", key: "email", operation: { op: "notExists" } },
          ];
          const rules: GroupRuleWithKey[] = [
            {
              type: "email",
              key: "email",
              match: "null",
              operation: { op: "eq" },
            },
          ];

          expect(group.toConvenientRules(rules)).toEqual(convenientRules);
        });

        test("relative_gt", async () => {
          const convenientRules = [
            {
              type: "date",
              key: "lastLoginAt",
              operation: { op: "relative_gt", description: "is in the past" },
              relativeMatchNumber: 1,
              relativeMatchUnit: "days",
            },
          ];
          const rules: GroupRuleWithKey[] = [
            {
              type: "date",
              key: "lastLoginAt",
              operation: { op: "gt" },
              relativeMatchDirection: "subtract",
              relativeMatchNumber: 1,
              relativeMatchUnit: "days",
            },
          ];

          expect(group.toConvenientRules(rules)).toEqual(convenientRules);
        });

        test("relative_lt", async () => {
          const convenientRules = [
            {
              type: "date",
              key: "lastLoginAt",
              operation: { op: "relative_lt", description: "is in the future" },
              relativeMatchNumber: 1,
              relativeMatchUnit: "days",
            },
          ];
          const rules: GroupRuleWithKey[] = [
            {
              type: "date",
              key: "lastLoginAt",
              operation: { op: "lt" },
              relativeMatchDirection: "add",
              relativeMatchNumber: 1,
              relativeMatchUnit: "days",
            },
          ];

          expect(group.toConvenientRules(rules)).toEqual(convenientRules);
        });
      });

      test("convenientRules work with rulesAreEqual", async () => {
        await group.setRules(
          group.fromConvenientRules([
            { key: "firstName", operation: { op: "exists" } },
          ])
        );
        const rules = await group.getRules();

        expect(
          GroupOps.rulesAreEqual(rules, [
            {
              key: "firstName",
              operation: { op: "ne" },
              match: "null",
            },
          ])
        ).toBe(true);
      });
    });

    describe("#updateProfilesMembership", () => {
      describe("group", () => {
        test("groups with no rules will not have members added", async () => {
          await group.setRules([]);
          const belongs = await group.updateRecordsMembership([mario]);
          expect(belongs).toEqual({ [mario.id]: false });
        });

        test("it will add a record not yet in the group", async () => {
          let members = await group.$get("groupMembers");
          expect(members.length).toBe(0);

          await group.update({ matchType: "all" });
          await group.setRules([
            { key: "lastName", match: "Mario", operation: { op: "eq" } },
            { key: "firstName", match: "Mario", operation: { op: "eq" } },
          ]);

          const belongs = await group.updateRecordsMembership([mario]);
          expect(belongs).toEqual({ [mario.id]: true });

          members = await group.$get("groupMembers");
          expect(members.length).toBe(1);
          expect(members[0].recordId).toBe(mario.id);
        });

        test("it works with multiple records", async () => {
          await group.update({ matchType: "all" });
          await group.setRules([
            { key: "lastName", match: "Mario", operation: { op: "eq" } },
            { key: "firstName", match: "Mario", operation: { op: "eq" } },
          ]);

          const belongs = await group.updateRecordsMembership([mario, luigi]);
          expect(belongs).toEqual({ [mario.id]: true, [luigi.id]: false });
        });

        test("it will leave a group member in the group", async () => {
          await group.update({ matchType: "all" });
          await group.setRules([
            { key: "lastName", match: "Mario", operation: { op: "eq" } },
            { key: "firstName", match: "Mario", operation: { op: "eq" } },
          ]);

          const belongsA = await group.updateRecordsMembership([mario]);
          expect(belongsA).toEqual({ [mario.id]: true });

          const belongsB = await group.updateRecordsMembership([mario]);
          expect(belongsB).toEqual({ [mario.id]: true });

          const members = await group.$get("groupMembers");
          expect(members.length).toBe(1);
          expect(members[0].recordId).toBe(mario.id);
        });

        test("it will remove a record from the group", async () => {
          await group.update({ matchType: "all" });
          await group.setRules([
            { key: "lastName", match: "Mario", operation: { op: "eq" } },
            { key: "firstName", match: "Mario", operation: { op: "eq" } },
          ]);

          let belongs = await group.updateRecordsMembership([mario]);
          expect(belongs).toEqual({ [mario.id]: true });

          let members = await group.$get("groupMembers");
          expect(members.length).toBe(1);
          expect(members[0].recordId).toBe(mario.id);

          await group.setRules([
            { key: "lastName", match: "Lakitu", operation: { op: "eq" } },
          ]);

          belongs = await group.updateRecordsMembership([mario]);
          expect(belongs).toEqual({ [mario.id]: false });

          members = await group.$get("groupMembers");
          expect(members.length).toBe(0);
        });
      });
    });

    describe("#countPotentialMembers", () => {
      test("it will count the records which would become members at the next run by default", async () => {
        await group.update({ matchType: "any" });
        await group.setRules([
          {
            key: "lastLoginAt",
            match: new Date(0).getTime(),
            operation: { op: "gt" },
          },
          { key: "lastName", match: "Mario", operation: { op: "like" } },
        ]);
        const count = await group.countPotentialMembers();
        expect(count).toBe(3);
      });

      test("it can return a count of records which would match an arbitrary rule set", async () => {
        await group.update({ matchType: "all" });
        await group.setRules([]);
        const rules: GroupRuleWithKey[] = [
          {
            key: "lastLoginAt",
            match: new Date(100000).getTime(),
            operation: { op: "gte" },
          },
          { key: "lastName", match: "Mario", operation: { op: "like" } },
        ];
        const count = await group.countPotentialMembers(rules);
        expect(count).toBe(1);
      });
    });

    describe("#countComponentMembersFromRules", () => {
      test("we can count the membership subtotals for each part of the group rule", async () => {
        await group.update({ matchType: "all" });
        await group.setRules([]);

        const rules: GroupRuleWithKey[] = [
          {
            key: "firstName",
            match: "%",
            operation: { op: "like" },
          },
          {
            key: "lastLoginAt",
            match: new Date(100000).getTime(),
            operation: { op: "gte" },
          },
          {
            key: "lastName",
            match: "Mario",
            operation: { op: "like" },
          },
        ];

        const count = await group.countPotentialMembers(rules);
        expect(count).toBe(1);

        const { componentCounts, funnelCounts } =
          await group.countComponentMembersFromRules(rules);

        // if we just had the rule `firstName: { match: "%", op: "iLike" },` there would be 4 group members
        // if we just had the rule `lastLoginAt: { match: new Date(100000).getTime(), op: "gte" }` there would be 2 group members
        // if we just had the rule `lastName: { match: "mario", op: "iLike" }` there would be 2 group members
        expect(componentCounts).toEqual([4, 2, 2]);

        // checking just the first rule (`firstName`) there would have been 2 group members
        // checking the first & second rule (`firstName` + `lastLoginAt`) there would have been 2 group members
        // checking all 3 rules (`firstName` + `lastLoginAt` + `lastLoginAt`), there would have been 1 group member
        expect(funnelCounts).toEqual([4, 2, 1]);
      });
    });
  });
});
