/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

const { Store } = ChromeUtils.importESModule(
  "resource://activity-stream/lib/Store.sys.mjs"
);
const { ActivityStreamMessageChannel } = ChromeUtils.importESModule(
  "resource://activity-stream/lib/ActivityStreamMessageChannel.sys.mjs"
);
const { ActivityStreamStorage } = ChromeUtils.importESModule(
  "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs"
);
const { sinon } = ChromeUtils.importESModule(
  "resource://testing-common/Sinon.sys.mjs"
);

// This creates the Redux top-level object.
/* globals Redux */
Services.scriptloader.loadSubScript(
  "resource://activity-stream/vendor/redux.js",
  this
);

add_task(async function test_expected_properties() {
  let sandbox = sinon.createSandbox();
  let store = new Store();

  Assert.equal(store.feeds.constructor.name, "Map", "Should create a Map");
  Assert.equal(store.feeds.size, 0, "Store should start without any feeds.");

  Assert.ok(store._store, "Has a ._store");
  Assert.ok(store.dispatch, "Has a .dispatch");
  Assert.ok(store.getState, "Has a .getState");

  sandbox.restore();
});

add_task(async function test_messagechannel() {
  let sandbox = sinon.createSandbox();
  sandbox
    .stub(ActivityStreamMessageChannel.prototype, "middleware")
    .returns(() => next => action => next(action));
  let store = new Store();

  info(
    "Store should create a ActivityStreamMessageChannel with the right dispatcher"
  );
  Assert.ok(store.getMessageChannel(), "Has a message channel");
  Assert.equal(
    store.getMessageChannel().dispatch,
    store.dispatch,
    "MessageChannel.dispatch forwards to store.dispatch"
  );
  Assert.equal(
    store.getMessageChannel(),
    store._messageChannel,
    "_messageChannel is the member for getMessageChannel()"
  );

  store.dispatch({ type: "FOO" });
  Assert.ok(
    ActivityStreamMessageChannel.prototype.middleware.calledOnce,
    "Middleware called."
  );
  sandbox.restore();
});

add_task(async function test_initFeed_add_feeds() {
  info("Store.initFeed should add an instance of the feed to .feeds");

  let sandbox = sinon.createSandbox();
  let store = new Store();
  class Foo {}
  store._prefs.set("foo", true);
  await store.init(new Map([["foo", () => new Foo()]]));
  store.initFeed("foo");

  Assert.ok(store.feeds.has("foo"), "foo is set");
  Assert.ok(store.feeds.get("foo") instanceof Foo, "Got registered class");
  sandbox.restore();
});

add_task(async function test_initFeed_calls_onAction() {
  info("Store should call the feed's onAction with uninit action if it exists");

  let sandbox = sinon.createSandbox();
  let store = new Store();
  let testFeed;
  let createTestFeed = () => {
    testFeed = { onAction: sandbox.spy() };
    return testFeed;
  };
  const action = { type: "FOO" };
  store._feedFactories = new Map([["test", createTestFeed]]);

  store.initFeed("test", action);

  Assert.ok(testFeed.onAction.calledOnce, "onAction called");
  Assert.ok(
    testFeed.onAction.calledWith(action),
    "onAction called with test action"
  );

  info("Store should add a .store property to the feed");
  Assert.ok(testFeed.store, "Store exists");
  Assert.equal(testFeed.store, store, "Feed store is the Store");
  sandbox.restore();
});

add_task(async function test_initFeed_on_init() {
  info("Store should call .initFeed with each key");

  let sandbox = sinon.createSandbox();
  let store = new Store();

  sandbox.stub(store, "initFeed");
  store._prefs.set("foo", true);
  store._prefs.set("bar", true);
  await store.init(
    new Map([
      ["foo", () => {}],
      ["bar", () => {}],
    ])
  );
  Assert.ok(store.initFeed.calledWith("foo"), "First test feed initted");
  Assert.ok(store.initFeed.calledWith("bar"), "Second test feed initted");
  sandbox.restore();
});

add_task(async function test_initFeed_calls__initIndexedDB() {
  info("Store should call _initIndexedDB");
  let sandbox = sinon.createSandbox();
  let store = new Store();

  sandbox.spy(store, "_initIndexedDB");

  let dbStub = sandbox.stub(ActivityStreamStorage.prototype, "db");
  let dbAccessed = false;
  dbStub.get(() => {
    dbAccessed = true;
    return {};
  });

  store._prefs.set("testfeed", true);
  await store.init(
    new Map([
      [
        "testfeed",
        () => {
          return {};
        },
      ],
    ])
  );

  Assert.ok(store._initIndexedDB.calledOnce, "_initIndexedDB called once");
  Assert.ok(
    store._initIndexedDB.calledWithExactly("feeds.telemetry"),
    "feeds.telemetry was passed"
  );
  // Due to what appears to be a bug in sinon when using calledOnce
  // with a stubbed getter, we can't just use dbStub.calledOnce here.
  Assert.ok(dbAccessed, "ActivityStreamStorage was accessed");

  info(
    "Store should reset ActivityStreamStorage telemetry if opening the db fails"
  );
  dbStub.rejects();
  await store.init(new Map());

  Assert.equal(
    store.dbStorage.telemetry,
    null,
    "Telemetry on storage was cleared"
  );

  sandbox.restore();
});

add_task(async function test_disabled_feed() {
  info("Store should not initialize the feed if the Pref is set to false");

  let sandbox = sinon.createSandbox();
  let store = new Store();

  sandbox.stub(store, "initFeed");
  store._prefs.set("foo", false);
  await store.init(new Map([["foo", () => {}]]));
  Assert.ok(store.initFeed.notCalled, ".initFeed not called");

  store._prefs.set("foo", true);

  sandbox.restore();
});

add_task(async function test_observe_pref_branch() {
  info("Store should observe the pref branch");

  let sandbox = sinon.createSandbox();
  let store = new Store();

  sandbox.stub(store._prefs, "observeBranch");
  await store.init(new Map());
  Assert.ok(store._prefs.observeBranch.calledOnce, "observeBranch called once");
  Assert.ok(
    store._prefs.observeBranch.calledWith(store),
    "observeBranch passed the store"
  );

  sandbox.restore();
});

add_task(async function test_emit_initial_event() {
  info("Store should emit an initial event if provided");

  let sandbox = sinon.createSandbox();
  let store = new Store();

  const action = { type: "FOO" };
  sandbox.stub(store, "dispatch");
  await store.init(new Map(), action);
  Assert.ok(store.dispatch.calledOnce, "Dispatch called once");
  Assert.ok(store.dispatch.calledWith(action), "Dispatch called with action");

  sandbox.restore();
});

add_task(async function test_initialize_telemetry_feed_first() {
  info("Store should initialize the telemetry feed first");

  let sandbox = sinon.createSandbox();
  let store = new Store();

  store._prefs.set("feeds.foo", true);
  store._prefs.set("feeds.telemetry", true);
  const telemetrySpy = sandbox.stub().returns({});
  const fooSpy = sandbox.stub().returns({});
  // Intentionally put the telemetry feed as the second item.
  const feedFactories = new Map([
    ["feeds.foo", fooSpy],
    ["feeds.telemetry", telemetrySpy],
  ]);
  await store.init(feedFactories);
  Assert.ok(telemetrySpy.calledBefore(fooSpy), "Telemetry feed initted first");

  sandbox.restore();
});

add_task(async function test_dispatch_init_load_events() {
  info("Store should dispatch init/load events");

  let sandbox = sinon.createSandbox();
  let store = new Store();

  sandbox.stub(store.getMessageChannel(), "simulateMessagesForExistingTabs");
  await store.init(new Map(), { type: "FOO" });
  Assert.ok(
    store.getMessageChannel().simulateMessagesForExistingTabs.calledOnce,
    "simulateMessagesForExistingTabs called once"
  );

  sandbox.restore();
});

add_task(async function test_init_before_load() {
  info("Store should dispatch INIT before LOAD");

  let sandbox = sinon.createSandbox();
  let store = new Store();

  sandbox.stub(store.getMessageChannel(), "simulateMessagesForExistingTabs");
  sandbox.stub(store, "dispatch");
  const init = { type: "INIT" };
  const load = { type: "TAB_LOAD" };
  store
    .getMessageChannel()
    .simulateMessagesForExistingTabs.callsFake(() => store.dispatch(load));
  await store.init(new Map(), init);

  Assert.ok(store.dispatch.calledTwice, "Dispatch called twice");
  Assert.equal(
    store.dispatch.firstCall.args[0],
    init,
    "First dispatch was for init event"
  );
  Assert.equal(
    store.dispatch.secondCall.args[0],
    load,
    "Second dispatch was for load event"
  );

  sandbox.restore();
});

add_task(async function test_uninit_feeds() {
  info("uninitFeed should not throw if no feed with that name exists");

  let sandbox = sinon.createSandbox();
  let store = new Store();

  try {
    store.uninitFeed("does-not-exist");
    Assert.ok(true, "Didn't throw");
  } catch (e) {
    Assert.ok(false, "Should not have thrown");
  }

  info(
    "uninitFeed should call the feed's onAction with uninit action if it exists"
  );
  let feed;
  function createFeed() {
    feed = { onAction: sandbox.spy() };
    return feed;
  }
  const action = { type: "BAR" };
  store._feedFactories = new Map([["foo", createFeed]]);
  store.initFeed("foo");

  store.uninitFeed("foo", action);

  Assert.ok(feed.onAction.calledOnce);
  Assert.ok(feed.onAction.calledWith(action));

  info("uninitFeed should remove the feed from .feeds");
  Assert.ok(!store.feeds.has("foo"), "foo is not in .feeds");

  sandbox.restore();
});

add_task(async function test_onPrefChanged() {
  let sandbox = sinon.createSandbox();
  let store = new Store();
  let initFeedStub = sandbox.stub(store, "initFeed");
  let uninitFeedStub = sandbox.stub(store, "uninitFeed");
  store._prefs.set("foo", false);
  store.init(new Map([["foo", () => ({})]]));

  info("onPrefChanged should initialize the feed if called with true");
  store.onPrefChanged("foo", true);
  Assert.ok(initFeedStub.calledWith("foo"));
  Assert.ok(!uninitFeedStub.calledOnce);
  initFeedStub.resetHistory();
  uninitFeedStub.resetHistory();

  info("onPrefChanged should uninitialize the feed if called with false");
  store.onPrefChanged("foo", false);
  Assert.ok(uninitFeedStub.calledWith("foo"));
  Assert.ok(!initFeedStub.calledOnce);
  initFeedStub.resetHistory();
  uninitFeedStub.resetHistory();

  info("onPrefChanged should do nothing if not an expected feed");
  store.onPrefChanged("bar", false);

  Assert.ok(!initFeedStub.calledOnce);
  Assert.ok(!uninitFeedStub.calledOnce);
  sandbox.restore();
});

add_task(async function test_uninit() {
  let sandbox = sinon.createSandbox();
  let store = new Store();
  let dispatchStub = sandbox.stub(store, "dispatch");
  const action = { type: "BAR" };
  await store.init(new Map(), null, action);
  store.uninit();

  Assert.ok(store.dispatch.calledOnce);
  Assert.ok(store.dispatch.calledWith(action));

  info("Store.uninit should clear .feeds and ._feedFactories");
  store._prefs.set("a", true);
  await store.init(
    new Map([
      ["a", () => ({})],
      ["b", () => ({})],
      ["c", () => ({})],
    ])
  );

  store.uninit();

  Assert.equal(store.feeds.size, 0);
  Assert.equal(store._feedFactories, null);

  info("Store.uninit should emit an uninit event if provided on init");
  dispatchStub.resetHistory();
  const uninitAction = { type: "BAR" };
  await store.init(new Map(), null, uninitAction);
  store.uninit();

  Assert.ok(store.dispatch.calledOnce);
  Assert.ok(store.dispatch.calledWith(uninitAction));
  sandbox.restore();
});

add_task(async function test_getState() {
  info("Store.getState should return the redux state");
  let sandbox = sinon.createSandbox();
  let store = new Store();
  store._store = Redux.createStore((prevState = 123) => prevState);
  const { getState } = store;
  Assert.equal(getState(), 123);
  sandbox.restore();
});

/**
 * addNumberReducer - a simple dummy reducer for testing that adds a number
 */
function addNumberReducer(prevState = 0, action) {
  return action.type === "ADD" ? prevState + action.data : prevState;
}

add_task(async function test_dispatch() {
  info("Store.dispatch should call .onAction of each feed");
  let sandbox = sinon.createSandbox();
  let store = new Store();
  const { dispatch } = store;
  const sub = { onAction: sinon.spy() };
  const action = { type: "FOO" };

  store._prefs.set("sub", true);
  await store.init(new Map([["sub", () => sub]]));

  dispatch(action);

  Assert.ok(sub.onAction.calledWith(action));

  info("Sandbox.dispatch should call the reducers");

  store._store = Redux.createStore(addNumberReducer);
  dispatch({ type: "ADD", data: 14 });
  Assert.equal(store.getState(), 14);

  sandbox.restore();
});

add_task(async function test_subscribe() {
  info("Store.subscribe should subscribe to changes to the store");
  let sandbox = sinon.createSandbox();
  let store = new Store();
  const sub = sandbox.spy();
  const action = { type: "FOO" };

  store.subscribe(sub);
  store.dispatch(action);

  Assert.ok(sub.calledOnce);
  sandbox.restore();
});
