/**
 * @license
 * Copyright Google LLC 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 {isBrowser} from '../../lib/common/utils';
import {isSafari, zoneSymbol} from '../test-util';

// simulate @angular/facade/src/error.ts
class BaseError extends Error {
  /** @internal **/
  _nativeError: Error;

  constructor(message: string) {
    super(message);
    const nativeError = new Error(message) as any as Error;
    this._nativeError = nativeError;
  }

  get message() {
    return this._nativeError.message;
  }
  set message(message) {
    this._nativeError.message = message;
  }
  get name() {
    return this._nativeError.name;
  }
  get stack() {
    return (this._nativeError as any).stack;
  }
  set stack(value) {
    (this._nativeError as any).stack = value;
  }
  toString() {
    return this._nativeError.toString();
  }
}

class WrappedError extends BaseError {
  originalError: any;

  constructor(message: string, error: any) {
    super(`${message} caused by: ${error instanceof Error ? error.message : error}`);
    this.originalError = error;
  }

  get stack() {
    return ((this.originalError instanceof Error ? this.originalError : this._nativeError) as any)
        .stack;
  }
}

class TestError extends WrappedError {
  constructor(message: string, error: any) {
    super(`${message} caused by: ${error instanceof Error ? error.message : error}`, error);
  }

  get message() {
    return 'test ' + this.originalError.message;
  }
}

class TestMessageError extends WrappedError {
  constructor(message: string, error: any) {
    super(`${message} caused by: ${error instanceof Error ? error.message : error}`, error);
  }

  get message() {
    return 'test ' + this.originalError.message;
  }

  set message(value) {
    this.originalError.message = value;
  }
}

describe('ZoneAwareError', () => {
  // If the environment does not supports stack rewrites, then these tests will fail
  // and there is no point in running them.
  const _global: any = typeof window !== 'undefined' ? window : global;
  let config: any;
  const __karma__ = _global.__karma__;
  if (typeof __karma__ !== 'undefined') {
    config = __karma__ && (__karma__ as any).config;
  } else if (typeof process !== 'undefined') {
    config = process.env;
  }
  const policy = (config && config['errorpolicy']) || 'default';
  if (!(Error as any)['stackRewrite'] && policy !== 'disable') return;

  it('should keep error prototype chain correctly', () => {
    class MyError extends Error {}
    const myError = new MyError();
    expect(myError instanceof Error).toBe(true);
    expect(myError instanceof MyError).toBe(true);
    expect(myError.stack).not.toBe(undefined);
  });

  it('should instanceof error correctly', () => {
    let myError = Error('myError');
    expect(myError instanceof Error).toBe(true);
    let myError1 = Error.call(undefined, 'myError');
    expect(myError1 instanceof Error).toBe(true);
    let myError2 = Error.call(global, 'myError');
    expect(myError2 instanceof Error).toBe(true);
    let myError3 = Error.call({}, 'myError');
    expect(myError3 instanceof Error).toBe(true);
    let myError4 = Error.call({test: 'test'}, 'myError');
    expect(myError4 instanceof Error).toBe(true);
  });

  it('should return error itself from constructor', () => {
    class MyError1 extends Error {
      constructor() {
        const err: any = super('MyError1');
        this.message = err.message;
      }
    }
    let myError1 = new MyError1();
    expect(myError1.message).toEqual('MyError1');
    expect(myError1.name).toEqual('Error');
  });

  it('should return error by calling error directly', () => {
    let myError = Error('myError');
    expect(myError.message).toEqual('myError');
    let myError1 = Error.call(undefined, 'myError');
    expect(myError1.message).toEqual('myError');
    let myError2 = Error.call(global, 'myError');
    expect(myError2.message).toEqual('myError');
    let myError3 = Error.call({}, 'myError');
    expect(myError3.message).toEqual('myError');
  });

  it('should have browser specified property', () => {
    let myError = new Error('myError');
    if (Object.prototype.hasOwnProperty.call(Error.prototype, 'description')) {
      // in IE, error has description property
      expect((<any>myError).description).toEqual('myError');
    }
    if (Object.prototype.hasOwnProperty.call(Error.prototype, 'fileName')) {
      // in firefox, error has fileName property
      expect((<any>myError).fileName).toBeTruthy();
    }
  });

  it('should not use child Error class get/set in ZoneAwareError constructor', () => {
    const func = () => {
      const error = new BaseError('test');
      expect(error.message).toEqual('test');
    };

    expect(func).not.toThrow();
  });

  it('should behave correctly with wrapped error', () => {
    const error = new TestError('originalMessage', new Error('error message'));
    expect(error.message).toEqual('test error message');
    error.originalError.message = 'new error message';
    expect(error.message).toEqual('test new error message');

    const error1 = new TestMessageError('originalMessage', new Error('error message'));
    expect(error1.message).toEqual('test error message');
    error1.message = 'new error message';
    expect(error1.message).toEqual('test new error message');
  });

  it('should copy customized NativeError properties to ZoneAwareError', () => {
    const spy = jasmine.createSpy('errorCustomFunction');
    const NativeError = (global as any)[(Zone as any).__symbol__('Error')];
    NativeError.customFunction = function(args: any) {
      spy(args);
    };
    expect((Error as any)['customProperty']).toBe('customProperty');
    expect(typeof (Error as any)['customFunction']).toBe('function');
    (Error as any)['customFunction']('test');
    expect(spy).toHaveBeenCalledWith('test');
  });

  it('should always have stack property even without throw', () => {
    // in IE, the stack will be undefined without throw
    // in ZoneAwareError, we will make stack always be
    // there event without throw
    const error = new Error('test');
    const errorWithoutNew = Error('test');
    expect(error.stack!.split('\n').length > 0).toBeTruthy();
    expect(errorWithoutNew.stack!.split('\n').length > 0).toBeTruthy();
  });

  it('should show zone names in stack frames and remove extra frames', () => {
    if (policy === 'disable' || !(Error as any)['stackRewrite']) {
      return;
    }
    if (isBrowser && isSafari()) {
      return;
    }
    const rootZone = Zone.root;
    const innerZone = rootZone.fork({name: 'InnerZone'});

    rootZone.run(testFn);
    function testFn() {
      let outside: any;
      let inside: any;
      let outsideWithoutNew: any;
      let insideWithoutNew: any;
      try {
        throw new Error('Outside');
      } catch (e) {
        outside = e;
      }
      try {
        throw Error('Outside');
      } catch (e) {
        outsideWithoutNew = e;
      }
      innerZone.run(function insideRun() {
        try {
          throw new Error('Inside');
        } catch (e) {
          inside = e;
        }
        try {
          throw Error('Inside');
        } catch (e) {
          insideWithoutNew = e;
        }
      });

      if (policy === 'lazy') {
        outside.stack = outside.zoneAwareStack;
        outsideWithoutNew.stack = outsideWithoutNew.zoneAwareStack;
        inside.stack = inside.zoneAwareStack;
        insideWithoutNew.stack = insideWithoutNew.zoneAwareStack;
      }

      expect(outside.stack).toEqual(outside.zoneAwareStack);
      expect(outsideWithoutNew.stack).toEqual(outsideWithoutNew.zoneAwareStack);
      expect(inside!.stack).toEqual(inside!.zoneAwareStack);
      expect(insideWithoutNew!.stack).toEqual(insideWithoutNew!.zoneAwareStack);
      expect(typeof inside!.originalStack).toEqual('string');
      expect(typeof insideWithoutNew!.originalStack).toEqual('string');
      const outsideFrames = outside.stack!.split(/\n/);
      const insideFrames = inside!.stack!.split(/\n/);
      const outsideWithoutNewFrames = outsideWithoutNew!.stack!.split(/\n/);
      const insideWithoutNewFrames = insideWithoutNew!.stack!.split(/\n/);

      // throw away first line if it contains the error
      if (/Outside/.test(outsideFrames[0])) {
        outsideFrames.shift();
      }
      if (/Error /.test(outsideFrames[0])) {
        outsideFrames.shift();
      }

      if (/Outside/.test(outsideWithoutNewFrames[0])) {
        outsideWithoutNewFrames.shift();
      }
      if (/Error /.test(outsideWithoutNewFrames[0])) {
        outsideWithoutNewFrames.shift();
      }

      if (/Inside/.test(insideFrames[0])) {
        insideFrames.shift();
      }
      if (/Error /.test(insideFrames[0])) {
        insideFrames.shift();
      }

      if (/Inside/.test(insideWithoutNewFrames[0])) {
        insideWithoutNewFrames.shift();
      }
      if (/Error /.test(insideWithoutNewFrames[0])) {
        insideWithoutNewFrames.shift();
      }
      expect(outsideFrames[0]).toMatch(/testFn.*[<root>]/);

      expect(insideFrames[0]).toMatch(/insideRun.*[InnerZone]]/);
      expect(insideFrames[1]).toMatch(/testFn.*[<root>]]/);

      expect(outsideWithoutNewFrames[0]).toMatch(/testFn.*[<root>]/);

      expect(insideWithoutNewFrames[0]).toMatch(/insideRun.*[InnerZone]]/);
      expect(insideWithoutNewFrames[1]).toMatch(/testFn.*[<root>]]/);
    }
  });

  const zoneAwareFrames = [
    'Zone.run', 'Zone.runGuarded', 'Zone.scheduleEventTask', 'Zone.scheduleMicroTask',
    'Zone.scheduleMacroTask', 'Zone.runTask', 'ZoneDelegate.scheduleTask',
    'ZoneDelegate.invokeTask', 'zoneAwareAddListener', 'Zone.prototype.run',
    'Zone.prototype.runGuarded', 'Zone.prototype.scheduleEventTask',
    'Zone.prototype.scheduleMicroTask', 'Zone.prototype.scheduleMacroTask',
    'Zone.prototype.runTask', 'ZoneDelegate.prototype.scheduleTask',
    'ZoneDelegate.prototype.invokeTask', 'ZoneTask.invokeTask'
  ];

  function assertStackDoesNotContainZoneFrames(err: any) {
    const frames = policy === 'lazy' ? err.zoneAwareStack.split('\n') : err.stack.split('\n');
    if (policy === 'disable') {
      let hasZoneStack = false;
      for (let i = 0; i < frames.length; i++) {
        if (hasZoneStack) {
          break;
        }
        hasZoneStack = zoneAwareFrames.filter(f => frames[i].indexOf(f) !== -1).length > 0;
      }
      if (!hasZoneStack) {
        console.log('stack', err.originalStack);
      }
      expect(hasZoneStack).toBe(true);
    } else {
      for (let i = 0; i < frames.length; i++) {
        expect(zoneAwareFrames.filter(f => frames[i].indexOf(f) !== -1)).toEqual([]);
      }
    }
  };

  const errorZoneSpec = {
    name: 'errorZone',
    done: <(() => void)|null>null,
    onHandleError:
        (parentDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, error: Error) => {
          assertStackDoesNotContainZoneFrames(error);
          setTimeout(() => {
            errorZoneSpec.done && errorZoneSpec.done();
          }, 0);
          return false;
        }
  };

  const errorZone = Zone.root.fork(errorZoneSpec);

  const assertStackDoesNotContainZoneFramesTest = function(testFn: Function) {
    return function(done: () => void) {
      errorZoneSpec.done = done;
      errorZone.run(testFn);
    };
  };

  describe('Error stack', () => {
    it('Error with new which occurs in setTimeout callback should not have zone frames visible',
       assertStackDoesNotContainZoneFramesTest(() => {
         setTimeout(() => {
           throw new Error('timeout test error');
         }, 10);
       }));

    it('Error without new which occurs in setTimeout callback should not have zone frames visible',
       assertStackDoesNotContainZoneFramesTest(() => {
         setTimeout(() => {
           throw Error('test error');
         }, 10);
       }));

    it('Error with new which cause by promise rejection should not have zone frames visible',
       (done) => {
         const p = new Promise((resolve, reject) => {
           setTimeout(() => {
             reject(new Error('test error'));
           });
         });
         p.catch(err => {
           assertStackDoesNotContainZoneFrames(err);
           done();
         });
       });

    it('Error without new which cause by promise rejection should not have zone frames visible',
       (done) => {
         const p = new Promise((resolve, reject) => {
           setTimeout(() => {
             reject(Error('test error'));
           });
         });
         p.catch(err => {
           assertStackDoesNotContainZoneFrames(err);
           done();
         });
       });

    it('Error with new which occurs in eventTask callback should not have zone frames visible',
       assertStackDoesNotContainZoneFramesTest(() => {
         const task = Zone.current.scheduleEventTask('errorEvent', () => {
           throw new Error('test error');
         }, undefined, () => null, undefined);
         task.invoke();
       }));

    it('Error without new which occurs in eventTask callback should not have zone frames visible',
       assertStackDoesNotContainZoneFramesTest(() => {
         const task = Zone.current.scheduleEventTask('errorEvent', () => {
           throw Error('test error');
         }, undefined, () => null, undefined);
         task.invoke();
       }));

    it('Error with new which occurs in longStackTraceZone should not have zone frames and longStackTraceZone frames visible',
       assertStackDoesNotContainZoneFramesTest(() => {
         const task = Zone.current.fork((Zone as any)['longStackTraceZoneSpec'])
                          .scheduleEventTask('errorEvent', () => {
                            throw new Error('test error');
                          }, undefined, () => null, undefined);
         task.invoke();
       }));

    it('Error without new which occurs in longStackTraceZone should not have zone frames and longStackTraceZone frames visible',
       assertStackDoesNotContainZoneFramesTest(() => {
         const task = Zone.current.fork((Zone as any)['longStackTraceZoneSpec'])
                          .scheduleEventTask('errorEvent', () => {
                            throw Error('test error');
                          }, undefined, () => null, undefined);
         task.invoke();
       }));

    it('stack frames of the callback in user customized zoneSpec should be kept',
       assertStackDoesNotContainZoneFramesTest(() => {
         const task = Zone.current.fork((Zone as any)['longStackTraceZoneSpec'])
                          .fork({
                            name: 'customZone',
                            onScheduleTask: (parentDelegate, currentZone, targetZone, task) => {
                              return parentDelegate.scheduleTask(targetZone, task);
                            },
                            onHandleError: (parentDelegate, currentZone, targetZone, error) => {
                              parentDelegate.handleError(targetZone, error);
                              const containsCustomZoneSpecStackTrace =
                                  error.stack.indexOf('onScheduleTask') !== -1;
                              expect(containsCustomZoneSpecStackTrace).toBeTruthy();
                              return false;
                            }
                          })
                          .scheduleEventTask('errorEvent', () => {
                            throw new Error('test error');
                          }, undefined, () => null, undefined);
         task.invoke();
       }));

    it('should be able to generate zone free stack even NativeError stack is readonly', function() {
      const _global: any =
          typeof window === 'object' && window || typeof self === 'object' && self || global;
      const NativeError = _global[zoneSymbol('Error')];
      const desc = Object.getOwnPropertyDescriptor(NativeError.prototype, 'stack');
      if (desc) {
        const originalSet: ((value: any) => void)|undefined = desc.set;
        // make stack readonly
        desc.set = null as any;

        try {
          const error = new Error('test error');
          expect(error.stack).toBeTruthy();
          assertStackDoesNotContainZoneFrames(error);
        } finally {
          desc.set = originalSet;
        }
      }
    });
  });
});
