const CommandRegistry = require('../src/command-registry');
const _ = require('underscore-plus');
const {it, fit, ffit, fffit, beforeEach, afterEach} = require('./async-spec-helpers');

describe("CommandRegistry", () => {
  let registry, parent, child, grandchild;

  beforeEach(() => {
    parent = document.createElement("div");
    child = document.createElement("div");
    grandchild = document.createElement("div");
    parent.classList.add('parent');
    child.classList.add('child');
    grandchild.classList.add('grandchild');
    child.appendChild(grandchild);
    parent.appendChild(child);
    document.querySelector('#jasmine-content').appendChild(parent);

    registry = new CommandRegistry;
    registry.attach(parent);
  });

  afterEach(() => registry.destroy());

  describe("when a command event is dispatched on an element", () => {
    it("invokes callbacks with selectors matching the target", () => {
      let called = false;
      registry.add('.grandchild', 'command', function (event) {
        expect(this).toBe(grandchild);
        expect(event.type).toBe('command');
        expect(event.eventPhase).toBe(Event.BUBBLING_PHASE);
        expect(event.target).toBe(grandchild);
        expect(event.currentTarget).toBe(grandchild);
        called = true;
      });

      grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
      expect(called).toBe(true);
    });

    it("invokes callbacks with selectors matching ancestors of the target", () => {
      const calls = [];

      registry.add('.child', 'command', function (event) {
        expect(this).toBe(child);
        expect(event.target).toBe(grandchild);
        expect(event.currentTarget).toBe(child);
        calls.push('child');
      });

      registry.add('.parent', 'command', function (event)  {
        expect(this).toBe(parent);
        expect(event.target).toBe(grandchild);
        expect(event.currentTarget).toBe(parent);
        calls.push('parent');
      });

      grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
      expect(calls).toEqual(['child', 'parent']);
  });

    it("invokes inline listeners prior to listeners applied via selectors", () => {
      const calls = [];
      registry.add('.grandchild', 'command', () => calls.push('grandchild'));
      registry.add(child, 'command', () => calls.push('child-inline'));
      registry.add('.child', 'command', () => calls.push('child'));
      registry.add('.parent', 'command', () => calls.push('parent'));

      grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
      expect(calls).toEqual(['grandchild', 'child-inline', 'child', 'parent']);
  });

    it("orders multiple matching listeners for an element by selector specificity", () => {
      child.classList.add('foo', 'bar');
      const calls = [];

      registry.add('.foo.bar', 'command', () => calls.push('.foo.bar'));
      registry.add('.foo', 'command', () => calls.push('.foo'));
      registry.add('.bar', 'command', () => calls.push('.bar')); // specificity ties favor commands added later, like CSS

      grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
      expect(calls).toEqual(['.foo.bar', '.bar', '.foo']);
  });

    it("orders inline listeners by reverse registration order", () => {
      const calls = [];
      registry.add(child, 'command', () => calls.push('child1'));
      registry.add(child, 'command', () => calls.push('child2'));
      child.dispatchEvent(new CustomEvent('command', {bubbles: true}));
      expect(calls).toEqual(['child2', 'child1']);
  });

    it("stops bubbling through ancestors when .stopPropagation() is called on the event", () => {
      const calls = [];

      registry.add('.parent', 'command', () => calls.push('parent'));
      registry.add('.child', 'command', () => calls.push('child-2'));
      registry.add('.child', 'command', (event) => {
        calls.push('child-1');
        event.stopPropagation();
      });

      const dispatchedEvent = new CustomEvent('command', {bubbles: true});
      spyOn(dispatchedEvent, 'stopPropagation');
      grandchild.dispatchEvent(dispatchedEvent);
      expect(calls).toEqual(['child-1', 'child-2']);
      expect(dispatchedEvent.stopPropagation).toHaveBeenCalled();
    });

    it("stops invoking callbacks when .stopImmediatePropagation() is called on the event", () => {
      const calls = [];

      registry.add('.parent', 'command', () => calls.push('parent'));
      registry.add('.child', 'command', () => calls.push('child-2'));
      registry.add('.child', 'command', (event) => {
        calls.push('child-1');
        event.stopImmediatePropagation();
      });

      const dispatchedEvent = new CustomEvent('command', {bubbles: true});
      spyOn(dispatchedEvent, 'stopImmediatePropagation');
      grandchild.dispatchEvent(dispatchedEvent);
      expect(calls).toEqual(['child-1']);
      expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled();
    });

    it("forwards .preventDefault() calls from the synthetic event to the original", () => {
      registry.add('.child', 'command', event => event.preventDefault());

      const dispatchedEvent = new CustomEvent('command', {bubbles: true});
      spyOn(dispatchedEvent, 'preventDefault');
      grandchild.dispatchEvent(dispatchedEvent);
      expect(dispatchedEvent.preventDefault).toHaveBeenCalled();
    });

    it("forwards .abortKeyBinding() calls from the synthetic event to the original", () => {
      registry.add('.child', 'command', event => event.abortKeyBinding());

      const dispatchedEvent = new CustomEvent('command', {bubbles: true});
      dispatchedEvent.abortKeyBinding = jasmine.createSpy('abortKeyBinding');
      grandchild.dispatchEvent(dispatchedEvent);
      expect(dispatchedEvent.abortKeyBinding).toHaveBeenCalled();
    });

    it("copies non-standard properties from the original event to the synthetic event", () => {
      let syntheticEvent = null;
      registry.add('.child', 'command', event => syntheticEvent = event);

      const dispatchedEvent = new CustomEvent('command', {bubbles: true});
      dispatchedEvent.nonStandardProperty = 'testing';
      grandchild.dispatchEvent(dispatchedEvent);
      expect(syntheticEvent.nonStandardProperty).toBe('testing');
    });

    it("allows listeners to be removed via a disposable returned by ::add", () => {
      let calls = [];

      const disposable1 = registry.add('.parent', 'command', () => calls.push('parent'));
      const disposable2 = registry.add('.child', 'command', () => calls.push('child'));

      disposable1.dispose();
      grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
      expect(calls).toEqual(['child']);

      calls = [];
      disposable2.dispose();
      grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
      expect(calls).toEqual([]);
  });

    it("allows multiple commands to be registered under one selector when called with an object", () => {
      let calls = [];

      const disposable = registry.add('.child', {
        'command-1'() {
          calls.push('command-1');
        },
        'command-2'() {
          calls.push('command-2');
        }
      });

      grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true}));
      grandchild.dispatchEvent(new CustomEvent('command-2', {bubbles: true}));

      expect(calls).toEqual(['command-1', 'command-2']);

      calls = [];
      disposable.dispose();
      grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true}));
      grandchild.dispatchEvent(new CustomEvent('command-2', {bubbles: true}));
      expect(calls).toEqual([]);
  });

    it("invokes callbacks registered with ::onWillDispatch and ::onDidDispatch", () => {
      const sequence = [];

      registry.onDidDispatch(event => sequence.push(['onDidDispatch', event]));

      registry.add('.grandchild', 'command', event => sequence.push(['listener', event]));

      registry.onWillDispatch(event => sequence.push(['onWillDispatch', event]));

      grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));

      expect(sequence[0][0]).toBe('onWillDispatch');
      expect(sequence[1][0]).toBe('listener');
      expect(sequence[2][0]).toBe('onDidDispatch');

      expect(sequence[0][1] === sequence[1][1] && sequence[1][1] === sequence[2][1]).toBe(true);
      expect(sequence[0][1].constructor).toBe(CustomEvent);
      expect(sequence[0][1].target).toBe(grandchild);
    });
  });

  describe("::add(selector, commandName, callback)", () => {
    it("throws an error when called with an invalid selector", () => {
      const badSelector = '<>';
      let addError = null;
      try {
        registry.add(badSelector, 'foo:bar', () => {});
      } catch (error) {
        addError = error;
      }
      expect(addError.message).toContain(badSelector);
    });

    it("throws an error when called with a null callback and selector target", () => {
      const badCallback = null;

      expect(() => {
        registry.add('.selector', 'foo:bar', badCallback);
      }).toThrow(new Error('Cannot register a command with a null listener.'));
    });

    it("throws an error when called with a null callback and object target", () => {
      const badCallback = null;

      expect(() => {
        registry.add(document.body, 'foo:bar', badCallback);
      }).toThrow(new Error('Cannot register a command with a null listener.'));
    });

    it("throws an error when called with an object listener without a didDispatch method", () => {
      const badListener = {
        title: 'a listener without a didDispatch callback',
        description: 'this should throw an error'
      };

      expect(() => {
        registry.add(document.body, 'foo:bar', badListener);
      }).toThrow(new Error('Listener must be a callback function or an object with a didDispatch method.'));
    });
  });

  describe("::findCommands({target})", () => {
    it("returns command descriptors that can be invoked on the target or its ancestors", () => {
      registry.add('.parent', 'namespace:command-1', () => {});
      registry.add('.child', 'namespace:command-2', () => {});
      registry.add('.grandchild', 'namespace:command-3', () => {});
      registry.add('.grandchild.no-match', 'namespace:command-4', () => {});

      registry.add(grandchild, 'namespace:inline-command-1', () => {});
      registry.add(child, 'namespace:inline-command-2', () => {});

      const commands = registry.findCommands({target: grandchild});
      const nonJqueryCommands = _.reject(commands, cmd => cmd.jQuery);
      expect(nonJqueryCommands).toEqual([
        {name: 'namespace:inline-command-1', displayName: 'Namespace: Inline Command 1'},
        {name: 'namespace:command-3', displayName: 'Namespace: Command 3'},
        {name: 'namespace:inline-command-2', displayName: 'Namespace: Inline Command 2'},
        {name: 'namespace:command-2', displayName: 'Namespace: Command 2'},
        {name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
      ]);
    });

    it("returns command descriptors with arbitrary metadata if set in a listener object", () => {
      registry.add('.grandchild', 'namespace:command-1', () => {});
      registry.add('.grandchild', 'namespace:command-2', {
        displayName: 'Custom Command 2',
        metadata: {
          some: 'other',
          object: 'data'
        },
        didDispatch() {}
      });
      registry.add('.grandchild', 'namespace:command-3', {
        name: 'some:other:incorrect:commandname',
        displayName: 'Custom Command 3',
        metadata: {
          some: 'other',
          object: 'data'
        },
        didDispatch() {}
      });

      const commands = registry.findCommands({target: grandchild});
      expect(commands).toEqual([
        {
          displayName: 'Namespace: Command 1',
          name: 'namespace:command-1'
        },
        {
          displayName: 'Custom Command 2',
          metadata: {
            some : 'other',
            object : 'data'
          },
          name: 'namespace:command-2'
        },
        {
          displayName: 'Custom Command 3',
          metadata: {
            some : 'other',
            object : 'data'
          },
          name: 'namespace:command-3'
        }
      ]);
    });

    it("returns command descriptors with arbitrary metadata if set on a listener function", () => {
      function listener () {}
      listener.displayName = 'Custom Command 2'
      listener.metadata = {
        some: 'other',
        object: 'data'
      };

      registry.add('.grandchild', 'namespace:command-2', listener);
      const commands = registry.findCommands({target: grandchild});
      expect(commands).toEqual([
        {
          displayName : 'Custom Command 2',
          metadata: {
            some: 'other',
            object: 'data'
          },
          name: 'namespace:command-2'
        }
      ]);
    });
  });

  describe("::dispatch(target, commandName)", () => {
    it("simulates invocation of the given command ", () => {
      let called = false;
      registry.add('.grandchild', 'command', function (event) {
        expect(this).toBe(grandchild);
        expect(event.type).toBe('command');
        expect(event.eventPhase).toBe(Event.BUBBLING_PHASE);
        expect(event.target).toBe(grandchild);
        expect(event.currentTarget).toBe(grandchild);
        called = true;
      });

      registry.dispatch(grandchild, 'command');
      expect(called).toBe(true);
    });

    it("returns a promise if any listeners matched the command", () => {
      registry.add('.grandchild', 'command', () => {});

      expect(registry.dispatch(grandchild, 'command').constructor.name).toBe("Promise");
      expect(registry.dispatch(grandchild, 'bogus')).toBe(null);
      expect(registry.dispatch(parent, 'command')).toBe(null);
    });

    it("returns a promise that resolves when the listeners resolve", async () => {
      jasmine.useRealClock();
      registry.add('.grandchild', 'command', () => 1);
      registry.add('.grandchild', 'command', () => Promise.resolve(2));
      registry.add('.grandchild', 'command', () => new Promise((resolve) => {
        setTimeout(() => { resolve(3); }, 1);
      }));

      const values = await registry.dispatch(grandchild, 'command');
      expect(values).toEqual([3, 2, 1]);
    });

    it("returns a promise that rejects when a listener is rejected", async () => {
      jasmine.useRealClock();
      registry.add('.grandchild', 'command', () => 1);
      registry.add('.grandchild', 'command', () => Promise.resolve(2));
      registry.add('.grandchild', 'command', () => new Promise((resolve, reject) => {
        setTimeout(() => { reject(3); }, 1);
      }));

      let value;
      try {
        value = await registry.dispatch(grandchild, 'command');
      } catch (err) {
        value = err;
      }
      expect(value).toBe(3);
    });
  });

  describe("::getSnapshot and ::restoreSnapshot", () =>
    it("removes all command handlers except for those in the snapshot", () => {
      registry.add('.parent', 'namespace:command-1', () => {});
      registry.add('.child', 'namespace:command-2', () => {});
      const snapshot = registry.getSnapshot();
      registry.add('.grandchild', 'namespace:command-3', () => {});

      expect(registry.findCommands({target: grandchild}).slice(0, 3)).toEqual([
        {name: 'namespace:command-3', displayName: 'Namespace: Command 3'},
        {name: 'namespace:command-2', displayName: 'Namespace: Command 2'},
        {name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
      ]);

      registry.restoreSnapshot(snapshot);

      expect(registry.findCommands({target: grandchild}).slice(0, 2)).toEqual([
        {name: 'namespace:command-2', displayName: 'Namespace: Command 2'},
        {name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
      ]);

      registry.add('.grandchild', 'namespace:command-3', () => {});
      registry.restoreSnapshot(snapshot);

      expect(registry.findCommands({target: grandchild}).slice(0, 2)).toEqual([
        {name: 'namespace:command-2', displayName: 'Namespace: Command 2'},
        {name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
      ]);
  })
);

  describe("::attach(rootNode)", () =>
    it("adds event listeners for any previously-added commands", () => {
      const registry2 = new CommandRegistry;

      const commandSpy = jasmine.createSpy('command-callback');
      registry2.add('.grandchild', 'command-1', commandSpy);

      grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true}));
      expect(commandSpy).not.toHaveBeenCalled();

      registry2.attach(parent);

      grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true}));
      expect(commandSpy).toHaveBeenCalled();
    })
  );
});
