import {
  ASRouterTargeting,
  CachedTargetingGetter,
  getSortedMessages,
} from "modules/ASRouterTargeting.sys.mjs";
import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs";
import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs";
import { GlobalOverrider } from "tests/unit/utils";

// Note that tests for the ASRouterTargeting environment can be found in
// test/functional/mochitest/browser_asrouter_targeting.js

describe("#CachedTargetingGetter", () => {
  const sixHours = 6 * 60 * 60 * 1000;
  let sandbox;
  let clock;
  let frecentStub;
  let topsitesCache;
  let globals;
  let doesAppNeedPinStub;
  let getAddonsByTypesStub;
  beforeEach(() => {
    sandbox = sinon.createSandbox();
    clock = sinon.useFakeTimers();
    frecentStub = sandbox.stub(
      global.NewTabUtils.activityStreamProvider,
      "getTopFrecentSites"
    );
    topsitesCache = new CachedTargetingGetter("getTopFrecentSites");
    globals = new GlobalOverrider();
    globals.set(
      "TargetingContext",
      class {
        static combineContexts() {
          return sinon.stub();
        }

        evalWithDefault() {
          return sinon.stub();
        }
      }
    );
    doesAppNeedPinStub = sandbox.stub().resolves();
    getAddonsByTypesStub = sandbox.stub().resolves();
  });

  afterEach(() => {
    sandbox.restore();
    clock.restore();
    globals.restore();
  });

  it("should cache allow for optional getter argument", async () => {
    let pinCachedGetter = new CachedTargetingGetter(
      "doesAppNeedPin",
      true,
      undefined,
      { doesAppNeedPin: doesAppNeedPinStub }
    );
    // Need to tick forward because Date.now() is stubbed
    clock.tick(sixHours);

    await pinCachedGetter.get();
    await pinCachedGetter.get();
    await pinCachedGetter.get();

    // Called once; cached request
    assert.calledOnce(doesAppNeedPinStub);

    // Called with option argument
    assert.calledWith(doesAppNeedPinStub, true);

    // Expire and call again
    clock.tick(sixHours);
    await pinCachedGetter.get();

    // Call goes through
    assert.calledTwice(doesAppNeedPinStub);

    let themesCachedGetter = new CachedTargetingGetter(
      "getAddonsByTypes",
      ["foo"],
      undefined,
      { getAddonsByTypes: getAddonsByTypesStub }
    );

    // Need to tick forward because Date.now() is stubbed
    clock.tick(sixHours);

    await themesCachedGetter.get();
    await themesCachedGetter.get();
    await themesCachedGetter.get();

    // Called once; cached request
    assert.calledOnce(getAddonsByTypesStub);

    // Called with option argument
    assert.calledWith(getAddonsByTypesStub, ["foo"]);

    // Expire and call again
    clock.tick(sixHours);
    await themesCachedGetter.get();

    // Call goes through
    assert.calledTwice(getAddonsByTypesStub);
  });

  it("should only make a request every 6 hours", async () => {
    frecentStub.resolves();
    clock.tick(sixHours);

    await topsitesCache.get();
    await topsitesCache.get();

    assert.calledOnce(
      global.NewTabUtils.activityStreamProvider.getTopFrecentSites
    );

    clock.tick(sixHours);

    await topsitesCache.get();

    assert.calledTwice(
      global.NewTabUtils.activityStreamProvider.getTopFrecentSites
    );
  });
  it("throws when failing getter", async () => {
    frecentStub.rejects(new Error("fake error"));
    clock.tick(sixHours);

    // assert.throws expect a function as the first parameter, try/catch is a
    // workaround
    let rejected = false;
    try {
      await topsitesCache.get();
    } catch (e) {
      rejected = true;
    }

    assert(rejected);
  });
  describe("sortMessagesByPriority", () => {
    it("should sort messages in descending priority order", async () => {
      const [m1, m2, m3 = { id: "m3" }] =
        await OnboardingMessageProvider.getUntranslatedMessages();
      const checkMessageTargetingStub = sandbox
        .stub(ASRouterTargeting, "checkMessageTargeting")
        .resolves(false);
      sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);

      await ASRouterTargeting.findMatchingMessage({
        messages: [
          { ...m1, priority: 0 },
          { ...m2, priority: 1 },
          { ...m3, priority: 2 },
        ],
        trigger: "testing",
      });

      assert.equal(checkMessageTargetingStub.callCount, 3);

      const [arg_m1] = checkMessageTargetingStub.firstCall.args;
      assert.equal(arg_m1.id, m3.id);

      const [arg_m2] = checkMessageTargetingStub.secondCall.args;
      assert.equal(arg_m2.id, m2.id);

      const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
      assert.equal(arg_m3.id, m1.id);
    });
    it("should sort messages with no priority last", async () => {
      const [m1, m2, m3 = { id: "m3" }] =
        await OnboardingMessageProvider.getUntranslatedMessages();
      const checkMessageTargetingStub = sandbox
        .stub(ASRouterTargeting, "checkMessageTargeting")
        .resolves(false);
      sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);

      await ASRouterTargeting.findMatchingMessage({
        messages: [
          { ...m1, priority: 0 },
          { ...m2, priority: undefined },
          { ...m3, priority: 2 },
        ],
        trigger: "testing",
      });

      assert.equal(checkMessageTargetingStub.callCount, 3);

      const [arg_m1] = checkMessageTargetingStub.firstCall.args;
      assert.equal(arg_m1.id, m3.id);

      const [arg_m2] = checkMessageTargetingStub.secondCall.args;
      assert.equal(arg_m2.id, m1.id);

      const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
      assert.equal(arg_m3.id, m2.id);
    });
    it("should keep the order of messages with same priority unchanged", async () => {
      const [m1, m2, m3 = { id: "m3" }] =
        await OnboardingMessageProvider.getUntranslatedMessages();
      const checkMessageTargetingStub = sandbox
        .stub(ASRouterTargeting, "checkMessageTargeting")
        .resolves(false);
      sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);

      await ASRouterTargeting.findMatchingMessage({
        messages: [
          { ...m1, priority: 2, targeting: undefined, rank: 1 },
          { ...m2, priority: undefined, targeting: undefined, rank: 1 },
          { ...m3, priority: 2, targeting: undefined, rank: 1 },
        ],
        trigger: "testing",
      });

      assert.equal(checkMessageTargetingStub.callCount, 3);

      const [arg_m1] = checkMessageTargetingStub.firstCall.args;
      assert.equal(arg_m1.id, m1.id);

      const [arg_m2] = checkMessageTargetingStub.secondCall.args;
      assert.equal(arg_m2.id, m3.id);

      const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
      assert.equal(arg_m3.id, m2.id);
    });
  });
});
describe("#isTriggerMatch", () => {
  let trigger;
  let message;
  beforeEach(() => {
    trigger = { id: "openURL" };
    message = { id: "openURL" };
  });
  it("should return false if trigger and candidate ids are different", () => {
    trigger.id = "trigger";
    message.id = "message";

    assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message));
    assert.isTrue(
      ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" })
    );
  });
  it("should return true if the message we check doesn't have trigger params or patterns", () => {
    // No params or patterns defined
    assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message));
  });
  it("should return false if the trigger does not have params defined", () => {
    message.params = {};

    // trigger.param is undefined
    assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message));
  });
  it("should return true if message params includes trigger host", () => {
    message.params = ["mozilla.org"];
    trigger.param = { host: "mozilla.org" };

    assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message));
  });
  it("should return true if message params includes trigger param.type", () => {
    message.params = ["ContentBlockingMilestone"];
    trigger.param = { type: "ContentBlockingMilestone" };

    assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message)));
  });
  it("should return true if message params match trigger mask", () => {
    // STATE_BLOCKED_FINGERPRINTING_CONTENT
    message.params = [0x00000040];
    trigger.param = { type: 538091584 };

    assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message)));
  });
});
describe("ASRouterTargeting", () => {
  let evalStub;
  let sandbox;
  let clock;
  let globals;
  let fakeTargetingContext;
  beforeEach(() => {
    sandbox = sinon.createSandbox();
    sandbox.replace(ASRouterTargeting, "Environment", {});
    clock = sinon.useFakeTimers();
    fakeTargetingContext = {
      combineContexts: sandbox.stub(),
      evalWithDefault: sandbox.stub().resolves(),
      setTelemetrySource: sandbox.stub(),
    };
    globals = new GlobalOverrider();
    globals.set(
      "TargetingContext",
      class {
        static combineContexts(...args) {
          return fakeTargetingContext.combineContexts.apply(sandbox, args);
        }

        setTelemetrySource(id) {
          fakeTargetingContext.setTelemetrySource(id);
        }

        evalWithDefault(expr) {
          return fakeTargetingContext.evalWithDefault(expr);
        }
      }
    );
    evalStub = fakeTargetingContext.evalWithDefault;
  });
  afterEach(() => {
    clock.restore();
    sandbox.restore();
    globals.restore();
  });
  it("should provide message.id as source", async () => {
    await ASRouterTargeting.checkMessageTargeting(
      {
        id: "message",
        targeting: "true",
      },
      fakeTargetingContext,
      sandbox.stub(),
      false
    );
    assert.calledOnce(fakeTargetingContext.evalWithDefault);
    assert.calledWithExactly(fakeTargetingContext.evalWithDefault, "true");
    assert.calledWithExactly(
      fakeTargetingContext.setTelemetrySource,
      "message"
    );
  });
  it("should cache evaluation result", async () => {
    evalStub.resolves(true);
    let targetingContext = new global.TargetingContext();

    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl1" },
      targetingContext,
      sandbox.stub(),
      true
    );
    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl2" },
      targetingContext,
      sandbox.stub(),
      true
    );
    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl1" },
      targetingContext,
      sandbox.stub(),
      true
    );

    assert.calledTwice(evalStub);
  });
  it("should not cache evaluation result", async () => {
    evalStub.resolves(true);
    let targetingContext = new global.TargetingContext();

    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl" },
      targetingContext,
      sandbox.stub(),
      false
    );
    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl" },
      targetingContext,
      sandbox.stub(),
      false
    );
    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl" },
      targetingContext,
      sandbox.stub(),
      false
    );

    assert.calledThrice(evalStub);
  });
  it("should expire cache entries", async () => {
    evalStub.resolves(true);
    let targetingContext = new global.TargetingContext();

    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl" },
      targetingContext,
      sandbox.stub(),
      true
    );
    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl" },
      targetingContext,
      sandbox.stub(),
      true
    );
    clock.tick(5 * 60 * 1000 + 1);
    await ASRouterTargeting.checkMessageTargeting(
      { targeting: "jexl" },
      targetingContext,
      sandbox.stub(),
      true
    );

    assert.calledTwice(evalStub);
  });

  describe("#findMatchingMessage", () => {
    let matchStub;
    let messages = [
      { id: "FOO", targeting: "match" },
      { id: "BAR", targeting: "match" },
      { id: "BAZ" },
    ];
    beforeEach(() => {
      matchStub = sandbox
        .stub(ASRouterTargeting, "_isMessageMatch")
        .callsFake(message => message.targeting === "match");
    });
    it("should return an array of matches if returnAll is true", async () => {
      assert.deepEqual(
        await ASRouterTargeting.findMatchingMessage({
          messages,
          returnAll: true,
        }),
        [
          { id: "FOO", targeting: "match" },
          { id: "BAR", targeting: "match" },
        ]
      );
    });
    it("should return an empty array if no matches were found and returnAll is true", async () => {
      matchStub.returns(false);
      assert.deepEqual(
        await ASRouterTargeting.findMatchingMessage({
          messages,
          returnAll: true,
        }),
        []
      );
    });
    it("should return the first match if returnAll is false", async () => {
      assert.deepEqual(
        await ASRouterTargeting.findMatchingMessage({
          messages,
        }),
        messages[0]
      );
    });
    it("should return null if if no matches were found and returnAll is false", async () => {
      matchStub.returns(false);
      assert.deepEqual(
        await ASRouterTargeting.findMatchingMessage({
          messages,
        }),
        null
      );
    });
  });
});

/**
 * Messages should be sorted in the following order:
 * 1. Rank
 * 2. Priority
 * 3. If the message has targeting
 * 4. Order or randomization, depending on input
 */
describe("getSortedMessages", () => {
  let globals = new GlobalOverrider();
  let sandbox;
  beforeEach(() => {
    globals.set({ ASRouterPreferences });
    sandbox = sinon.createSandbox();
  });
  afterEach(() => {
    sandbox.restore();
    globals.restore();
  });

  /**
   * assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages,
   *                        returns the items in the expected order.
   *
   * @param {Message[]} expectedOrderArray - The array of messages in its expected order
   * @param {{}} options - The options param for getSortedMessages
   * @returns
   */
  function assertSortsCorrectly(expectedOrderArray, options) {
    const input = [...expectedOrderArray].reverse();
    const result = getSortedMessages(input, options);
    const indexes = result.map(message => expectedOrderArray.indexOf(message));
    return assert.equal(
      indexes.join(","),
      [...expectedOrderArray.keys()].join(","),
      "Messsages are out of order"
    );
  }

  it("should sort messages by priority, then by targeting", () => {
    assertSortsCorrectly([
      { priority: 100, targeting: "isFoo" },
      { priority: 100 },
      { priority: 99 },
      { priority: 1, targeting: "isFoo" },
      { priority: 1 },
      {},
    ]);
  });
  it("should sort messages by priority, then targeting, then order if ordered param is true", () => {
    assertSortsCorrectly(
      [
        { priority: 100, order: 4 },
        { priority: 100, order: 5 },
        { priority: 1, order: 3, targeting: "isFoo" },
        { priority: 1, order: 0 },
        { priority: 1, order: 1 },
        { priority: 1, order: 2 },
        { order: 0 },
      ],
      { ordered: true }
    );
  });
});
