/* eslint-env es6 */
/* eslint no-global-assign: 0 */
"use strict";

const mock = require('mock-require');
const { requireUncached, serialize, smoothExp } = require('./TestTools');
const consoleOriginal = global.console;
const DateOriginal = global.Date;

const runExample = options => {
  const { 
    Matter,
    logs,
    frameCallbacks
  } = prepareEnvironment(options);

  let memoryDeltaAverage = 0;
  let timeDeltaAverage = 0;
  let overlapTotal = 0;
  let overlapCount = 0;
  let i;
  let j;

  try {
    let runner;
    let engine;
    let render;
    let extrinsicCapture;

    const pairOverlap = (pair) => {
      const collision = Matter.Collision.collides(pair.bodyA, pair.bodyB);
      return collision ? Math.max(collision.depth - pair.slop, 0) : -1;
    };

    for (i = 0; i < options.repeats; i += 1) {
      if (global.gc) {
        global.gc();
      }

      const Examples = requireUncached('../examples/index');
      const example = Examples[options.name]();

      runner = example.runner;
      engine = example.engine;
      render = example.render;

      for (j = 0; j < options.updates; j += 1) {
        const time = j * runner.delta;
        const callbackCount = frameCallbacks.length;

        global.timeNow = time;

        for (let p = 0; p < callbackCount; p += 1) {
          const frameCallback = frameCallbacks.shift();
          const memoryBefore = process.memoryUsage().heapUsed;
          const timeBefore = process.hrtime();

          frameCallback(time);

          const timeDuration = process.hrtime(timeBefore);
          const timeDelta = timeDuration[0] * 1e9 + timeDuration[1];
          const memoryAfter = process.memoryUsage().heapUsed;
          const memoryDelta = Math.max(memoryAfter - memoryBefore, 0);

          memoryDeltaAverage = smoothExp(memoryDeltaAverage, memoryDelta);
          timeDeltaAverage = smoothExp(timeDeltaAverage, timeDelta);
        }

        let overlapTotalUpdate = 0;
        let overlapCountUpdate = 0;

        const pairsList = engine.pairs.list;
        const pairsListLength = engine.pairs.list.length;

        for (let p = 0; p < pairsListLength; p += 1) {
          const pair = pairsList[p];

          if (pair.isActive && !pair.isSensor){
            const overlap = pairOverlap(pair);

            if (overlap >= 0) {
              overlapTotalUpdate += overlap;
              overlapCountUpdate += 1;
            }
          }
        }

        if (overlapCountUpdate > 0) {
          overlapTotal += overlapTotalUpdate / overlapCountUpdate;
          overlapCount += 1;
        }

        if (!extrinsicCapture && engine.timing.timestamp >= 1000) {
          extrinsicCapture = captureExtrinsics(engine, Matter);
          extrinsicCapture.updates = j;
        }
      }
    }

    resetEnvironment();

    return {
      name: options.name,
      duration: timeDeltaAverage,
      memory: memoryDeltaAverage,
      overlap: overlapTotal / (overlapCount || 1),
      extrinsic: extrinsicCapture,
      intrinsic: captureIntrinsics(engine, Matter),
      state: captureState(engine, runner, render),
      logs
    };

  } catch (err) {
    err.message = `On example '${options.name}' update ${j}:\n\n  ${err.message}`;
    throw err;
  }
};

const prepareMatter = (options) => {
  const Matter = requireUncached(options.useDev ? '../build/matter.dev' : '../build/matter');

  if (Matter.Common._nextId !== 0) {
    throw 'Matter instance has already been used.';
  }

  Matter.Common.info = Matter.Common.warn = Matter.Common.log;

  if (options.stableSort) {
    if (Matter.Collision) {
      const MatterCollisionCollides = Matter.Collision.collides;
      Matter.Collision.collides = function(bodyA, bodyB, pairs) {
        const _bodyA = bodyA.id < bodyB.id ? bodyA : bodyB;
        const _bodyB = bodyA.id < bodyB.id ? bodyB : bodyA;
        return MatterCollisionCollides(_bodyA, _bodyB, pairs);
      };
    } else {
      const MatterSATCollides = Matter.SAT.collides;
      Matter.SAT.collides = function(bodyA, bodyB, previousCollision, pairActive) {
        const _bodyA = bodyA.id < bodyB.id ? bodyA : bodyB;
        const _bodyB = bodyA.id < bodyB.id ? bodyB : bodyA;
        return MatterSATCollides(_bodyA, _bodyB, previousCollision, pairActive);
      };
    }

    Matter.after('Detector.collisions', function() { this.sort(collisionCompareId); });
    Matter.after('Composite.allBodies', function() { sortById(this); });
    Matter.after('Composite.allConstraints', function() { sortById(this); });
    Matter.after('Composite.allComposites', function() { sortById(this); });

    Matter.before('Pairs.update', function(pairs) {
      pairs.list.sort((pairA, pairB) => collisionCompareId(pairA.collision, pairB.collision));
    });

    Matter.after('Pairs.update', function(pairs) {
      pairs.list.sort((pairA, pairB) => collisionCompareId(pairA.collision, pairB.collision));
    });
  }

  if (options.jitter) {
    Matter.after('Body.create', function() {
      Matter.Body.applyForce(this, this.position, { 
        x: Math.cos(this.id * this.id) * options.jitter * this.mass, 
        y: Math.sin(this.id * this.id) * options.jitter * this.mass
      });
    });
  }
  
  return Matter;
};

const prepareEnvironment = options => {
  const logs = [];
  const frameCallbacks = [];

  global.document = global.window = {
    performance: {},
    addEventListener: () => {},
    requestAnimationFrame: callback => {
      frameCallbacks.push(callback);
      return frameCallbacks.length;
    },
    createElement: () => ({
      parentNode: {},
      width: 800,
      height: 600,
      style: {},
      addEventListener: () => {},
      getAttribute: name => ({
        'data-pixel-ratio': '1'
      }[name]),
      getContext: () => new Proxy({}, {
        get() { return () => {}; }
      })
    })
  };

  global.document.body = global.document.createElement();

  global.Image = function Image() { };

  global.console = { 
    log: (...args) => {
      logs.push(args.join(' '));
    }
  };

  global.Math.random = () => {
    throw new Error("Math.random was called during tests, output can not be compared.");
  };

  global.timeNow = 0;

  global.window.performance.now = () => global.timeNow;

  global.Date = function() {
    this.toString = () => global.timeNow.toString();
    this.valueOf = () => global.timeNow;
  };

  global.Date.now = () => global.timeNow;

  const Matter = prepareMatter(options);
  mock('matter-js', Matter);
  global.Matter = Matter;

  return {
    Matter,
    logs,
    frameCallbacks
  };
};

const resetEnvironment = () => {
  global.console = consoleOriginal;
  global.Date = DateOriginal;
  global.window = undefined;
  global.document = undefined;
  global.Matter = undefined;
  mock.stopAll();
};

const captureExtrinsics = ({ world }, Matter) => ({
  bodies: Matter.Composite.allBodies(world).reduce((bodies, body) => {
      bodies[body.id] = {
        position: { x: body.position.x, y: body.position.y },
        vertices: body.vertices.map(vertex => ({ x: vertex.x, y: vertex.y }))
      };

      return bodies;
  }, {})
});

const captureIntrinsics = ({ world, timing }, Matter) => serialize({
  engine: {
    timing: {
      timeScale: timing.timeScale,
      timestamp: timing.timestamp
    }
  },
  bodies: Matter.Composite.allBodies(world).reduce((bodies, body) => {
      bodies[body.id] = body;
      return bodies;
  }, {}),
  constraints: Matter.Composite.allConstraints(world).reduce((constraints, constraint) => {
      constraints[constraint.id] = constraint;
      return constraints;
  }, {}),
  composites: Matter.Composite.allComposites(world).reduce((composites, composite) => {
      composites[composite.id] = {
          bodies: Matter.Composite.allBodies(composite).map(body => body.id), 
          constraints: Matter.Composite.allConstraints(composite).map(constraint => constraint.id), 
          composites: Matter.Composite.allComposites(composite).map(composite => composite.id)
      };
      return composites;
  }, {})
}, (key) => !Number.isInteger(parseInt(key)) && !intrinsicProperties.includes(key));

const captureState = (engine, runner, render, excludeKeys=excludeStateProperties) => (
  serialize({ engine, runner, render }, (key) => excludeKeys.includes(key))
);

const intrinsicProperties = [
  // Composite
  'bodies', 'constraints', 'composites',

  // Common
  'id', 'label', 

  // Constraint
  'angularStiffness', 'bodyA', 'bodyB', 'damping', 'length', 'stiffness',

  // Body
  'area', 'collisionFilter', 'category', 'mask', 'group', 'density', 'friction', 
  'frictionAir', 'frictionStatic', 'inertia', 'inverseInertia', 'inverseMass', 
  'isSensor', 'isSleeping', 'isStatic', 'mass', 'parent', 'parts', 'restitution', 
  'sleepThreshold', 'slop', 'timeScale',

  // Composite
  'bodies', 'constraints', 'composites'
];

const extrinsicProperties = [
  'axes',
  'vertices',
  'bounds',
  'angle',
  'anglePrev',
  'angularVelocity',
  'angularSpeed',
  'speed',
  'velocity',
  'position',
  'positionPrev',
  'motion',
  'sleepCounter',
  'positionImpulse'
];

const excludeStateProperties = [
  'cache',
  'grid',
  'context',
  'broadphase',
  'metrics',
  'controller',
  'detector',
  'pairs',
  'lastElapsed',
  'deltaHistory',
  'elapsedHistory',
  'engineDeltaHistory',
  'engineElapsedHistory',
  'timestampElapsedHistory',
].concat(extrinsicProperties);

const collisionId = (collision) => 
  Math.min(collision.bodyA.id, collision.bodyB.id) + Math.max(collision.bodyA.id, collision.bodyB.id) * 10000;

const collisionCompareId = (collisionA, collisionB) => collisionId(collisionA) - collisionId(collisionB);

const sortById = (objs) => objs.sort((objA, objB) => objA.id - objB.id);

module.exports = { runExample };