/*
 * Copyright The OpenTelemetry Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import * as assert from 'assert';
import {
  AsyncHooksContextManager,
  AsyncLocalStorageContextManager,
} from '../src';
import { EventEmitter } from 'events';
import { createContextKey, ROOT_CONTEXT } from '@opentelemetry/api';

for (const contextManagerClass of [
  AsyncHooksContextManager,
  AsyncLocalStorageContextManager,
]) {
  describe(contextManagerClass.name, () => {
    let contextManager:
      | AsyncHooksContextManager
      | AsyncLocalStorageContextManager;
    const key1 = createContextKey('test key 1');
    let otherContextManager:
      | AsyncHooksContextManager
      | AsyncLocalStorageContextManager;

    beforeEach(() => {
      contextManager = new contextManagerClass();
      contextManager.enable();
    });

    afterEach(() => {
      contextManager.disable();
      otherContextManager?.disable();
    });

    describe('.enable()', () => {
      it('should work', () => {
        assert.doesNotThrow(() => {
          contextManager = new contextManagerClass();
          assert.ok(
            contextManager.enable() === contextManager,
            'should return this'
          );
        });
      });
    });

    describe('.disable()', () => {
      it('should work', () => {
        assert.doesNotThrow(() => {
          assert.ok(
            contextManager.disable() === contextManager,
            'should return this'
          );
        });
        contextManager.enable();
      });
    });

    describe('.with()', () => {
      it('should run the callback (null as target)', done => {
        contextManager.with(ROOT_CONTEXT, done);
      });

      it('should run the callback (object as target)', done => {
        const test = ROOT_CONTEXT.setValue(key1, 1);
        contextManager.with(test, () => {
          assert.strictEqual(
            contextManager.active(),
            test,
            'should have context'
          );
          return done();
        });
      });

      it('should run the callback (when disabled)', done => {
        contextManager.disable();
        contextManager.with(ROOT_CONTEXT, () => {
          contextManager.enable();
          return done();
        });
      });

      it('should rethrow errors', done => {
        assert.throws(() => {
          contextManager.with(ROOT_CONTEXT, () => {
            throw new Error('This should be rethrown');
          });
        });
        return done();
      });

      it('should forward this, arguments and return value', () => {
        function fnWithThis(this: string, a: string, b: number): string {
          assert.strictEqual(this, 'that');
          assert.strictEqual(arguments.length, 2);
          assert.strictEqual(a, 'one');
          assert.strictEqual(b, 2);
          return 'done';
        }

        const res = contextManager.with(
          ROOT_CONTEXT,
          fnWithThis,
          'that',
          'one',
          2
        );
        assert.strictEqual(res, 'done');

        assert.strictEqual(
          contextManager.with(ROOT_CONTEXT, () => 3.14),
          3.14
        );
      });

      it('should finally restore an old context', done => {
        const ctx1 = ROOT_CONTEXT.setValue(key1, 'ctx1');
        const ctx2 = ROOT_CONTEXT.setValue(key1, 'ctx2');
        contextManager.with(ctx1, () => {
          assert.strictEqual(contextManager.active(), ctx1);
          contextManager.with(ctx2, () => {
            assert.strictEqual(contextManager.active(), ctx2);
          });
          assert.strictEqual(contextManager.active(), ctx1);
          return done();
        });
      });

      it('should finally restore an old context', done => {
        const ctx1 = ROOT_CONTEXT.setValue(key1, 'ctx1');
        contextManager.with(ctx1, () => {
          assert.strictEqual(contextManager.active(), ctx1);
          setTimeout(() => {
            assert.strictEqual(contextManager.active(), ctx1);
            return done();
          });
        });
      });

      it('async function called from nested "with" sync function should return nested context', done => {
        const scope1 = '1' as any;
        const scope2 = '2' as any;

        const asyncFuncCalledDownstreamFromSync = async () => {
          await (async () => {})();
          assert.strictEqual(contextManager.active(), scope2);
          return done();
        };

        contextManager.with(scope1, () => {
          assert.strictEqual(contextManager.active(), scope1);
          contextManager.with(scope2, () =>
            asyncFuncCalledDownstreamFromSync()
          );
          assert.strictEqual(contextManager.active(), scope1);
        });
        assert.strictEqual(contextManager.active(), ROOT_CONTEXT);
      });

      it('should not loose the context', done => {
        const scope1 = '1' as any;

        contextManager.with(scope1, async () => {
          assert.strictEqual(contextManager.active(), scope1);
          await new Promise(resolve => setTimeout(resolve, 100));
          assert.strictEqual(contextManager.active(), scope1);
          return done();
        });
        assert.strictEqual(contextManager.active(), ROOT_CONTEXT);
      });

      it('should correctly restore context using async/await', async () => {
        const scope1 = '1' as any;
        const scope2 = '2' as any;
        const scope3 = '3' as any;
        const scope4 = '4' as any;

        await contextManager.with(scope1, async () => {
          assert.strictEqual(contextManager.active(), scope1);
          await contextManager.with(scope2, async () => {
            assert.strictEqual(contextManager.active(), scope2);
            await contextManager.with(scope3, async () => {
              assert.strictEqual(contextManager.active(), scope3);
              await contextManager.with(scope4, async () => {
                assert.strictEqual(contextManager.active(), scope4);
              });
              assert.strictEqual(contextManager.active(), scope3);
            });
            assert.strictEqual(contextManager.active(), scope2);
          });
          assert.strictEqual(contextManager.active(), scope1);
        });
        assert.strictEqual(contextManager.active(), ROOT_CONTEXT);
      });

      it('should works with multiple concurrent operations', done => {
        const scope1 = '1' as any;
        const scope2 = '2' as any;
        const scope3 = '3' as any;
        const scope4 = '4' as any;
        let scope4Called = false;

        contextManager.with(scope1, async () => {
          assert.strictEqual(contextManager.active(), scope1);
          setTimeout(async () => {
            await contextManager.with(scope3, async () => {
              assert.strictEqual(contextManager.active(), scope3);
            });
            assert.strictEqual(contextManager.active(), scope1);
            assert.strictEqual(scope4Called, true);
            return done();
          }, 100);
          assert.strictEqual(contextManager.active(), scope1);
        });
        assert.strictEqual(contextManager.active(), ROOT_CONTEXT);
        contextManager.with(scope2, async () => {
          assert.strictEqual(contextManager.active(), scope2);
          setTimeout(() => {
            contextManager.with(scope4, async () => {
              assert.strictEqual(contextManager.active(), scope4);
              scope4Called = true;
            });
            assert.strictEqual(contextManager.active(), scope2);
          }, 20);
          assert.strictEqual(contextManager.active(), scope2);
        });
        assert.strictEqual(contextManager.active(), ROOT_CONTEXT);
      });

      it('should work with timers using the same timeout', done => {
        let cnt = 3;
        function countDown() {
          cnt--;
          if (cnt === 0) done();
          if (cnt < 0) throw new Error('too many calls to countDown()');
        }

        const time1 = 2;
        const time2 = time1 + 1;
        const rootCtx = contextManager.active();
        const innerCtx = rootCtx.setValue(Symbol('test'), 23);
        contextManager.with(innerCtx, () => {
          setTimeout(() => {
            assert.strictEqual(contextManager.active(), innerCtx);
            countDown();
          }, time1);
        });
        setTimeout(() => {
          assert.strictEqual(contextManager.active(), rootCtx);
          countDown();
        }, time1);
        setTimeout(() => {
          assert.strictEqual(contextManager.active(), rootCtx);
          countDown();
        }, time2);
      });

      it('should not influence other instances', () => {
        otherContextManager = new contextManagerClass();
        otherContextManager.enable();

        const context = ROOT_CONTEXT.setValue(key1, 2);
        const otherContext = ROOT_CONTEXT.setValue(key1, 3);
        contextManager.with(context, () => {
          assert.strictEqual(contextManager.active(), context);
          assert.strictEqual(otherContextManager.active(), ROOT_CONTEXT);
          otherContextManager.with(otherContext, () => {
            assert.strictEqual(contextManager.active(), context);
            assert.strictEqual(otherContextManager.active(), otherContext);
          });
        });
      });
    });

    describe('.bind(function)', () => {
      it('should return the same target (when enabled)', () => {
        const test = { a: 1 };
        assert.deepStrictEqual(contextManager.bind(ROOT_CONTEXT, test), test);
      });

      it('should return the same target (when disabled)', () => {
        contextManager.disable();
        const test = { a: 1 };
        assert.deepStrictEqual(contextManager.bind(ROOT_CONTEXT, test), test);
        contextManager.enable();
      });

      it('should return current context (when enabled)', done => {
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const fn = contextManager.bind(context, () => {
          assert.strictEqual(
            contextManager.active(),
            context,
            'should have context'
          );
          return done();
        });
        fn();
      });

      /**
       * Even if asynchooks is disabled, the context propagation will
       * still works but it might be lost after any async op.
       */
      it('should return current context (when disabled)', done => {
        contextManager.disable();
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const fn = contextManager.bind(context, () => {
          assert.strictEqual(
            contextManager.active(),
            context,
            'should have context'
          );
          return done();
        });
        fn();
      });

      it('should fail to return current context with async op', done => {
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const fn = contextManager.bind(context, () => {
          assert.strictEqual(contextManager.active(), context);
          setTimeout(() => {
            assert.strictEqual(
              contextManager.active(),
              context,
              'should have no context'
            );
            return done();
          }, 100);
        });
        fn();
      });

      it('should not influence other instances', () => {
        otherContextManager = new contextManagerClass();
        otherContextManager.enable();

        const context = ROOT_CONTEXT.setValue(key1, 2);
        const otherContext = ROOT_CONTEXT.setValue(key1, 3);
        const fn = otherContextManager.bind(
          otherContext,
          contextManager.bind(context, () => {
            assert.strictEqual(contextManager.active(), context);
            assert.strictEqual(otherContextManager.active(), otherContext);
          })
        );
        fn();
      });
    });

    describe('.bind(event-emitter)', () => {
      it('should return the same target (when enabled)', () => {
        const ee = new EventEmitter();
        assert.deepStrictEqual(contextManager.bind(ROOT_CONTEXT, ee), ee);
      });

      it('should return the same target (when disabled)', () => {
        const ee = new EventEmitter();
        contextManager.disable();
        assert.deepStrictEqual(contextManager.bind(ROOT_CONTEXT, ee), ee);
      });

      it('should return current context and removeListener (when enabled)', done => {
        const ee = new EventEmitter();
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const patchedEE = contextManager.bind(context, ee);
        const handler = () => {
          assert.deepStrictEqual(contextManager.active(), context);
          patchedEE.removeListener('test', handler);
          assert.strictEqual(patchedEE.listeners('test').length, 0);
          return done();
        };
        patchedEE.on('test', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 1);
        patchedEE.emit('test');
      });

      it('should return current context and removeAllListener (when enabled)', done => {
        const ee = new EventEmitter();
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const patchedEE = contextManager.bind(context, ee);
        const handler = () => {
          assert.deepStrictEqual(contextManager.active(), context);
          patchedEE.removeAllListeners('test');
          assert.strictEqual(patchedEE.listeners('test').length, 0);
          return done();
        };
        patchedEE.on('test', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 1);
        patchedEE.emit('test');
      });

      it('should remove event handler enabled by .once using removeListener (when enabled)', () => {
        const ee = new EventEmitter();
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const patchedEE = contextManager.bind(context, ee);
        function handler() {}
        patchedEE.once('test', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 1);
        patchedEE.removeListener('test', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 0);
      });

      it('should remove event handler enabled by .once using off (when enabled)', () => {
        const ee = new EventEmitter();
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const patchedEE = contextManager.bind(context, ee);
        const handler = () => {};
        patchedEE.once('test', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 1);
        patchedEE.off('test', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 0);
      });

      it('should return current context and removeAllListeners (when enabled)', done => {
        const ee = new EventEmitter();
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const patchedEE = contextManager.bind(context, ee);
        const handler = () => {
          assert.deepStrictEqual(contextManager.active(), context);
          patchedEE.removeAllListeners();
          assert.strictEqual(patchedEE.listeners('test').length, 0);
          assert.strictEqual(patchedEE.listeners('test1').length, 0);
          return done();
        };
        patchedEE.on('test', handler);
        patchedEE.on('test1', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 1);
        assert.strictEqual(patchedEE.listeners('test1').length, 1);
        patchedEE.emit('test');
      });

      /**
       * Even if asynchooks is disabled, the context propagation will
       * still works but it might be lost after any async op.
       */
      it('should return context (when disabled)', done => {
        contextManager.disable();
        const ee = new EventEmitter();
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const patchedEE = contextManager.bind(context, ee);
        const handler = () => {
          assert.deepStrictEqual(contextManager.active(), context);
          patchedEE.removeListener('test', handler);
          assert.strictEqual(patchedEE.listeners('test').length, 0);
          return done();
        };
        patchedEE.on('test', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 1);
        patchedEE.emit('test');
      });

      it('should not return current context with async op', done => {
        const ee = new EventEmitter();
        const context = ROOT_CONTEXT.setValue(key1, 1);
        const patchedEE = contextManager.bind(context, ee);
        const handler = () => {
          assert.deepStrictEqual(contextManager.active(), context);
          setImmediate(() => {
            assert.deepStrictEqual(contextManager.active(), context);
            patchedEE.removeAllListeners('test');
            assert.strictEqual(patchedEE.listeners('test').length, 0);
            return done();
          });
        };
        patchedEE.on('test', handler);
        assert.strictEqual(patchedEE.listeners('test').length, 1);
        patchedEE.emit('test');
      });

      it('should not influence other instances', () => {
        const ee = new EventEmitter();
        otherContextManager = new contextManagerClass();
        otherContextManager.enable();

        const context = ROOT_CONTEXT.setValue(key1, 2);
        const otherContext = ROOT_CONTEXT.setValue(key1, 3);
        const patchedEE = otherContextManager.bind(
          otherContext,
          contextManager.bind(context, ee)
        );
        const handler = () => {
          assert.strictEqual(contextManager.active(), context);
          assert.strictEqual(otherContextManager.active(), otherContext);
        };

        patchedEE.on('test', handler);
        patchedEE.emit('test');
      });
    });
  });
}
