/**
 * @license
 * Copyright Google Inc. All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */
import {AnimationEvent, AnimationMetadata, AnimationTriggerMetadata, NoopAnimationPlayer, animate, state, style, transition, trigger} from '@angular/animations';

import {TriggerAst} from '../../src/dsl/animation_ast';
import {buildAnimationAst} from '../../src/dsl/animation_ast_builder';
import {buildTrigger} from '../../src/dsl/animation_trigger';
import {AnimationStyleNormalizer, NoopAnimationStyleNormalizer} from '../../src/dsl/style_normalization/animation_style_normalizer';
import {getBodyNode} from '../../src/render/shared';
import {TransitionAnimationEngine} from '../../src/render/transition_animation_engine';
import {MockAnimationDriver, MockAnimationPlayer} from '../../testing/src/mock_animation_driver';

const DEFAULT_NAMESPACE_ID = 'id';

(function() {
  const driver = new MockAnimationDriver();

  // these tests are only mean't to be run within the DOM
  if (isNode) return;

  describe('TransitionAnimationEngine', () => {
    let element: any;

    beforeEach(() => {
      MockAnimationDriver.log = [];
      element = document.createElement('div');
      document.body.appendChild(element);
    });

    afterEach(() => { document.body.removeChild(element); });

    function makeEngine(normalizer?: AnimationStyleNormalizer) {
      const engine = new TransitionAnimationEngine(
          getBodyNode(), driver, normalizer || new NoopAnimationStyleNormalizer());
      engine.createNamespace(DEFAULT_NAMESPACE_ID, element);
      return engine;
    }

    describe('trigger registration', () => {
      it('should ignore and not throw an error if the same trigger is registered twice', () => {
        // TODO (matsko): ask why this is avoided
        const engine = makeEngine();
        registerTrigger(element, engine, trigger('trig', []));
        expect(() => { registerTrigger(element, engine, trigger('trig', [])); }).not.toThrow();
      });
    });

    describe('property setting', () => {
      it('should invoke a transition based on a property change', () => {
        const engine = makeEngine();
        const trig = trigger('myTrigger', [
          transition('* => *', [style({height: '0px'}), animate(1000, style({height: '100px'}))])
        ]);

        registerTrigger(element, engine, trig);
        setProperty(element, engine, 'myTrigger', 'value');
        engine.flush();
        expect(engine.players.length).toEqual(1);

        const player = MockAnimationDriver.log.pop() as MockAnimationPlayer;
        expect(player.keyframes).toEqual([
          {height: '0px', offset: 0}, {height: '100px', offset: 1}
        ]);
      });

      it('should not queue an animation if the property value has not changed at all', () => {
        const engine = makeEngine();

        const trig = trigger('myTrigger', [
          transition('* => *', [style({height: '0px'}), animate(1000, style({height: '100px'}))])
        ]);

        registerTrigger(element, engine, trig);
        engine.flush();
        expect(engine.players.length).toEqual(0);

        setProperty(element, engine, 'myTrigger', 'abc');
        engine.flush();
        expect(engine.players.length).toEqual(1);

        setProperty(element, engine, 'myTrigger', 'abc');
        engine.flush();
        expect(engine.players.length).toEqual(1);
      });

      it('should throw an error if an animation property without a matching trigger is changed',
         () => {
           const engine = makeEngine();
           expect(() => {
             setProperty(element, engine, 'myTrigger', 'no');
           }).toThrowError(/The provided animation trigger "myTrigger" has not been registered!/);
         });
    });

    describe('removal operations', () => {
      it('should cleanup all inner state that\'s tied to an element once removed', () => {
        const engine = makeEngine();

        const trig = trigger('myTrigger', [
          transition(':leave', [style({height: '0px'}), animate(1000, style({height: '100px'}))])
        ]);

        registerTrigger(element, engine, trig);
        setProperty(element, engine, 'myTrigger', 'value');
        engine.flush();

        expect(engine.elementContainsData(DEFAULT_NAMESPACE_ID, element)).toBeTruthy();

        engine.removeNode(DEFAULT_NAMESPACE_ID, element, true, true);
        engine.flush();

        expect(engine.elementContainsData(DEFAULT_NAMESPACE_ID, element)).toBeTruthy();
      });

      it('should create and recreate a namespace for a host element with the same component source',
         () => {
           const engine = makeEngine();

           const trig =
               trigger('myTrigger', [transition('* => *', animate(1234, style({color: 'red'})))]);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, 'myTrigger', 'value');
           engine.flush();
           expect((engine.players[0].getRealPlayer() as MockAnimationPlayer).duration)
               .toEqual(1234);

           engine.destroy(DEFAULT_NAMESPACE_ID, null);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, 'myTrigger', 'value2');
           engine.flush();
           expect((engine.players[0].getRealPlayer() as MockAnimationPlayer).duration)
               .toEqual(1234);
         });
    });

    describe('event listeners', () => {
      it('should listen to the onStart operation for the animation', () => {
        const engine = makeEngine();

        const trig = trigger('myTrigger', [
          transition('* => *', [style({height: '0px'}), animate(1000, style({height: '100px'}))])
        ]);

        let count = 0;
        registerTrigger(element, engine, trig);
        listen(element, engine, 'myTrigger', 'start', () => count++);
        setProperty(element, engine, 'myTrigger', 'value');
        expect(count).toEqual(0);

        engine.flush();
        expect(count).toEqual(1);
      });

      it('should listen to the onDone operation for the animation', () => {
        const engine = makeEngine();

        const trig = trigger('myTrigger', [
          transition('* => *', [style({height: '0px'}), animate(1000, style({height: '100px'}))])
        ]);

        let count = 0;
        registerTrigger(element, engine, trig);
        listen(element, engine, 'myTrigger', 'done', () => count++);
        setProperty(element, engine, 'myTrigger', 'value');
        expect(count).toEqual(0);

        engine.flush();
        expect(count).toEqual(0);

        engine.players[0].finish();
        expect(count).toEqual(1);
      });

      it('should throw an error when an event is listened to that isn\'t supported', () => {
        const engine = makeEngine();
        const trig = trigger('myTrigger', []);
        registerTrigger(element, engine, trig);

        expect(() => { listen(element, engine, 'myTrigger', 'explode', () => {}); })
            .toThrowError(
                /The provided animation trigger event "explode" for the animation trigger "myTrigger" is not supported!/);
      });

      it('should throw an error when an event is listened for a trigger that doesn\'t exist', () => {
        const engine = makeEngine();
        expect(() => { listen(element, engine, 'myTrigger', 'explode', () => {}); })
            .toThrowError(
                /Unable to listen on the animation trigger event "explode" because the animation trigger "myTrigger" doesn\'t exist!/);
      });

      it('should throw an error when an undefined event is listened for', () => {
        const engine = makeEngine();
        const trig = trigger('myTrigger', []);
        registerTrigger(element, engine, trig);
        expect(() => { listen(element, engine, 'myTrigger', '', () => {}); })
            .toThrowError(
                /Unable to listen on the animation trigger "myTrigger" because the provided event is undefined!/);
      });

      it('should retain event listeners and call them for successive animation state changes',
         () => {
           const engine = makeEngine();
           const trig = trigger(
               'myTrigger',
               [transition(
                   '* => *', [style({height: '0px'}), animate(1000, style({height: '100px'}))])]);

           registerTrigger(element, engine, trig);

           let count = 0;
           listen(element, engine, 'myTrigger', 'start', () => count++);

           setProperty(element, engine, 'myTrigger', '123');
           engine.flush();
           expect(count).toEqual(1);

           setProperty(element, engine, 'myTrigger', '456');
           engine.flush();
           expect(count).toEqual(2);
         });

      it('should only fire event listener changes for when the corresponding trigger changes state',
         () => {
           const engine = makeEngine();
           const trig1 = trigger(
               'myTrigger1',
               [transition(
                   '* => 123', [style({height: '0px'}), animate(1000, style({height: '100px'}))])]);
           registerTrigger(element, engine, trig1);

           const trig2 = trigger(
               'myTrigger2',
               [transition(
                   '* => 123', [style({width: '0px'}), animate(1000, style({width: '100px'}))])]);
           registerTrigger(element, engine, trig2);

           let count = 0;
           listen(element, engine, 'myTrigger1', 'start', () => count++);

           setProperty(element, engine, 'myTrigger1', '123');
           engine.flush();
           expect(count).toEqual(1);

           setProperty(element, engine, 'myTrigger2', '123');
           engine.flush();
           expect(count).toEqual(1);
         });

      it('should allow a listener to be deregistered, but only after a flush occurs', () => {
        const engine = makeEngine();
        const trig = trigger(
            'myTrigger',
            [transition(
                '* => 123', [style({height: '0px'}), animate(1000, style({height: '100px'}))])]);
        registerTrigger(element, engine, trig);

        let count = 0;
        const deregisterFn = listen(element, engine, 'myTrigger', 'start', () => count++);
        setProperty(element, engine, 'myTrigger', '123');
        engine.flush();
        expect(count).toEqual(1);

        deregisterFn();
        engine.flush();

        setProperty(element, engine, 'myTrigger', '456');
        engine.flush();
        expect(count).toEqual(1);
      });

      it('should trigger a listener callback with an AnimationEvent argument', () => {
        const engine = makeEngine();
        registerTrigger(
            element, engine, trigger('myTrigger', [
              transition(
                  '* => *', [style({height: '0px'}), animate(1234, style({height: '100px'}))])
            ]));

        // we do this so that the next transition has a starting value that isn't null
        setProperty(element, engine, 'myTrigger', '123');
        engine.flush();

        let capture: AnimationEvent = null !;
        listen(element, engine, 'myTrigger', 'start', e => capture = e);
        listen(element, engine, 'myTrigger', 'done', e => capture = e);
        setProperty(element, engine, 'myTrigger', '456');
        engine.flush();

        delete (capture as any)['_data'];
        expect(capture).toEqual({
          element,
          triggerName: 'myTrigger',
          phaseName: 'start',
          fromState: '123',
          toState: '456',
          totalTime: 1234,
          disabled: false
        });

        capture = null !;
        const player = engine.players.pop() !;
        player.finish();

        delete (capture as any)['_data'];
        expect(capture).toEqual({
          element,
          triggerName: 'myTrigger',
          phaseName: 'done',
          fromState: '123',
          toState: '456',
          totalTime: 1234,
          disabled: false
        });
      });
    });

    describe('transition operations', () => {
      it('should persist the styles on the element as actual styles once the animation is complete',
         () => {
           const engine = makeEngine();
           const trig = trigger('something', [
             state('on', style({height: '100px'})), state('off', style({height: '0px'})),
             transition('on => off', animate(9876))
           ]);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, trig.name, 'on');
           setProperty(element, engine, trig.name, 'off');
           engine.flush();

           expect(element.style.height).not.toEqual('0px');
           engine.players[0].finish();
           expect(element.style.height).toEqual('0px');
         });

      it('should remove all existing state styling from an element when a follow-up transition occurs on the same trigger',
         () => {
           const engine = makeEngine();
           const trig = trigger('something', [
             state('a', style({height: '100px'})), state('b', style({height: '500px'})),
             state('c', style({width: '200px'})), transition('* => *', animate(9876))
           ]);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, trig.name, 'a');
           setProperty(element, engine, trig.name, 'b');
           engine.flush();

           const player1 = engine.players[0];
           player1.finish();
           expect(element.style.height).toEqual('500px');

           setProperty(element, engine, trig.name, 'c');
           engine.flush();

           const player2 = engine.players[0];
           expect(element.style.height).not.toEqual('500px');
           player2.finish();
           expect(element.style.width).toEqual('200px');
           expect(element.style.height).not.toEqual('500px');
         });

      it('should allow two animation transitions with different triggers to animate in parallel',
         () => {
           const engine = makeEngine();
           const trig1 = trigger('something1', [
             state('a', style({width: '100px'})), state('b', style({width: '200px'})),
             transition('* => *', animate(1000))
           ]);

           const trig2 = trigger('something2', [
             state('x', style({height: '500px'})), state('y', style({height: '1000px'})),
             transition('* => *', animate(2000))
           ]);

           registerTrigger(element, engine, trig1);
           registerTrigger(element, engine, trig2);

           let doneCount = 0;
           function doneCallback() { doneCount++; }

           setProperty(element, engine, trig1.name, 'a');
           setProperty(element, engine, trig1.name, 'b');
           setProperty(element, engine, trig2.name, 'x');
           setProperty(element, engine, trig2.name, 'y');
           engine.flush();

           const player1 = engine.players[0] !;
           player1.onDone(doneCallback);
           expect(doneCount).toEqual(0);

           const player2 = engine.players[1] !;
           player2.onDone(doneCallback);
           expect(doneCount).toEqual(0);

           player1.finish();
           expect(doneCount).toEqual(1);

           player2.finish();
           expect(doneCount).toEqual(2);

           expect(element.style.width).toEqual('200px');
           expect(element.style.height).toEqual('1000px');
         });

      it('should cancel a previously running animation when a follow-up transition kicks off on the same trigger',
         () => {
           const engine = makeEngine();
           const trig = trigger('something', [
             state('x', style({opacity: 0})),
             state('y', style({opacity: .5})),
             state('z', style({opacity: 1})),
             transition('* => *', animate(1000)),
           ]);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, trig.name, 'x');
           setProperty(element, engine, trig.name, 'y');
           engine.flush();

           expect(parseFloat(element.style.opacity)).not.toEqual(.5);

           const player1 = engine.players[0];
           setProperty(element, engine, trig.name, 'z');
           engine.flush();

           const player2 = engine.players[0];

           expect(parseFloat(element.style.opacity)).not.toEqual(.5);

           player2.finish();
           expect(parseFloat(element.style.opacity)).toEqual(1);

           player1.finish();
           expect(parseFloat(element.style.opacity)).toEqual(1);
         });

      it('should pass in the previously running players into the follow-up transition player when cancelled',
         () => {
           const engine = makeEngine();
           const trig = trigger('something', [
             state('x', style({opacity: 0})), state('y', style({opacity: .5})),
             state('z', style({opacity: 1})), transition('* => *', animate(1000))
           ]);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, trig.name, 'x');
           setProperty(element, engine, trig.name, 'y');
           engine.flush();

           const player1 = MockAnimationDriver.log.pop() !as MockAnimationPlayer;
           player1.setPosition(0.5);

           setProperty(element, engine, trig.name, 'z');
           engine.flush();

           const player2 = MockAnimationDriver.log.pop() !as MockAnimationPlayer;
           expect(player2.previousPlayers).toEqual([player1]);
           player2.finish();

           setProperty(element, engine, trig.name, 'x');
           engine.flush();

           const player3 = MockAnimationDriver.log.pop() !as MockAnimationPlayer;
           expect(player3.previousPlayers).toEqual([]);
         });

      it('should cancel all existing players if a removal animation is set to occur', () => {
        const engine = makeEngine();
        const trig = trigger('something', [
          state('m', style({opacity: 0})), state('n', style({opacity: 1})),
          transition('* => *', animate(1000))
        ]);

        registerTrigger(element, engine, trig);
        setProperty(element, engine, trig.name, 'm');
        setProperty(element, engine, trig.name, 'n');
        engine.flush();

        let doneCount = 0;
        function doneCallback() { doneCount++; }

        const player1 = engine.players[0];
        player1.onDone(doneCallback);

        expect(doneCount).toEqual(0);

        setProperty(element, engine, trig.name, 'void');
        engine.flush();

        expect(doneCount).toEqual(1);
      });

      it('should only persist styles that exist in the final state styles and not the last keyframe',
         () => {
           const engine = makeEngine();
           const trig = trigger('something', [
             state('0', style({width: '0px'})), state('1', style({width: '100px'})),
             transition('* => *', [animate(1000, style({height: '200px'}))])
           ]);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, trig.name, '0');
           setProperty(element, engine, trig.name, '1');
           engine.flush();

           const player = engine.players[0] !;
           expect(element.style.width).not.toEqual('100px');

           player.finish();
           expect(element.style.height).not.toEqual('200px');
           expect(element.style.width).toEqual('100px');
         });

      it('should default to using styling from the `*` state if a matching state is not found',
         () => {
           const engine = makeEngine();
           const trig = trigger('something', [
             state('a', style({opacity: 0})), state('*', style({opacity: .5})),
             transition('* => *', animate(1000))
           ]);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, trig.name, 'a');
           setProperty(element, engine, trig.name, 'z');
           engine.flush();

           engine.players[0].finish();
           expect(parseFloat(element.style.opacity)).toEqual(.5);
         });

      it('should treat `void` as `void`', () => {
        const engine = makeEngine();
        const trig = trigger('something', [
          state('a', style({opacity: 0})), state('void', style({opacity: .8})),
          transition('* => *', animate(1000))
        ]);

        registerTrigger(element, engine, trig);
        setProperty(element, engine, trig.name, 'a');
        setProperty(element, engine, trig.name, 'void');
        engine.flush();

        engine.players[0].finish();
        expect(parseFloat(element.style.opacity)).toEqual(.8);
      });
    });

    describe('style normalizer', () => {
      it('should normalize the style values that are animateTransitioned within an a transition animation',
         () => {
           const engine = makeEngine(new SuffixNormalizer('-normalized'));

           const trig = trigger('something', [
             state('on', style({height: 100})), state('off', style({height: 0})),
             transition('on => off', animate(9876))
           ]);

           registerTrigger(element, engine, trig);
           setProperty(element, engine, trig.name, 'on');
           setProperty(element, engine, trig.name, 'off');
           engine.flush();

           const player = MockAnimationDriver.log.pop() as MockAnimationPlayer;
           expect(player.keyframes).toEqual([
             {'height-normalized': '100-normalized', offset: 0},
             {'height-normalized': '0-normalized', offset: 1}
           ]);
         });

      it('should throw an error when normalization fails within a transition animation', () => {
        const engine = makeEngine(new ExactCssValueNormalizer({left: '100px'}));

        const trig = trigger('something', [
          state('a', style({left: '0px', width: '200px'})),
          state('b', style({left: '100px', width: '100px'})), transition('a => b', animate(9876))
        ]);

        registerTrigger(element, engine, trig);
        setProperty(element, engine, trig.name, 'a');
        setProperty(element, engine, trig.name, 'b');

        let errorMessage = '';
        try {
          engine.flush();
        } catch (e) {
          errorMessage = e.toString();
        }

        expect(errorMessage).toMatch(/Unable to animate due to the following errors:/);
        expect(errorMessage).toMatch(/- The CSS property `left` is not allowed to be `0px`/);
        expect(errorMessage).toMatch(/- The CSS property `width` is not allowed/);
      });
    });

    describe('view operations', () => {
      it('should perform insert operations immediately ', () => {
        const engine = makeEngine();

        const child1 = document.createElement('div');
        const child2 = document.createElement('div');
        element.appendChild(child1);
        element.appendChild(child2);

        element.appendChild(child1);
        engine.insertNode(DEFAULT_NAMESPACE_ID, child1, element, true);
        element.appendChild(child2);
        engine.insertNode(DEFAULT_NAMESPACE_ID, child2, element, true);

        expect(element.contains(child1)).toBe(true);
        expect(element.contains(child2)).toBe(true);
      });

      it('should not throw an error if a missing namespace is used', () => {
        const engine = makeEngine();
        const ID = 'foo';
        const TRIGGER = 'fooTrigger';
        expect(() => { engine.trigger(ID, element, TRIGGER, 'something'); }).not.toThrow();
      });

      it('should still apply state-styling to an element even if it is not yet inserted into the DOM',
         () => {
           const engine = makeEngine();
           const orphanElement = document.createElement('div');
           orphanElement.classList.add('orphan');

           registerTrigger(
               orphanElement, engine, trigger('trig', [
                 state('go', style({opacity: 0.5})), transition('* => go', animate(1000))
               ]));

           setProperty(orphanElement, engine, 'trig', 'go');
           engine.flush();
           expect(engine.players.length).toEqual(0);
           expect(orphanElement.style.opacity).toEqual('0.5');
         });
    });
  });
})();

class SuffixNormalizer extends AnimationStyleNormalizer {
  constructor(private _suffix: string) { super(); }

  normalizePropertyName(propertyName: string, errors: string[]): string {
    return propertyName + this._suffix;
  }

  normalizeStyleValue(
      userProvidedProperty: string, normalizedProperty: string, value: string|number,
      errors: string[]): string {
    return value + this._suffix;
  }
}

class ExactCssValueNormalizer extends AnimationStyleNormalizer {
  constructor(private _allowedValues: {[propName: string]: any}) { super(); }

  normalizePropertyName(propertyName: string, errors: string[]): string {
    if (!this._allowedValues[propertyName]) {
      errors.push(`The CSS property \`${propertyName}\` is not allowed`);
    }
    return propertyName;
  }

  normalizeStyleValue(
      userProvidedProperty: string, normalizedProperty: string, value: string|number,
      errors: string[]): string {
    const expectedValue = this._allowedValues[userProvidedProperty];
    if (expectedValue != value) {
      errors.push(`The CSS property \`${userProvidedProperty}\` is not allowed to be \`${value}\``);
    }
    return expectedValue;
  }
}

function registerTrigger(
    element: any, engine: TransitionAnimationEngine, metadata: AnimationTriggerMetadata,
    id: string = DEFAULT_NAMESPACE_ID) {
  const errors: any[] = [];
  const driver = new MockAnimationDriver();
  const name = metadata.name;
  const ast = buildAnimationAst(driver, metadata as AnimationMetadata, errors) as TriggerAst;
  if (errors.length) {
  }
  const trigger = buildTrigger(name, ast);
  engine.register(id, element);
  engine.registerTrigger(id, name, trigger);
}

function setProperty(
    element: any, engine: TransitionAnimationEngine, property: string, value: any,
    id: string = DEFAULT_NAMESPACE_ID) {
  engine.trigger(id, element, property, value);
}

function listen(
    element: any, engine: TransitionAnimationEngine, eventName: string, phaseName: string,
    callback: (event: any) => any, id: string = DEFAULT_NAMESPACE_ID) {
  return engine.listen(id, element, eventName, phaseName, callback);
}
