/**
 * @license
 * Copyright 2017 Google LLC. All Rights Reserved.
 * 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
 *
 * http://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 {ENGINE} from './engine';
import {inferShape} from './tensor_util_env';
import {RecursiveArray, TensorLike, TypedArray} from './types';
import {arraysEqual, encodeString, flatten, isString, isTypedArray} from './util';

const TEST_EPSILON_FLOAT32 = 1e-3;
export const TEST_EPSILON_FLOAT16 = 1e-1;

export function expectArraysClose(
    actual: TypedArray|number|RecursiveArray<number>,
    expected: TypedArray|number|RecursiveArray<number>, epsilon?: number) {
  if (epsilon == null) {
    epsilon = testEpsilon();
  }
  return expectArraysPredicate(
      actual, expected, (a, b) => areClose(a as number, b as number, epsilon));
}

export function testEpsilon() {
  return ENGINE.backend.floatPrecision() === 32 ? TEST_EPSILON_FLOAT32 :
                                                  TEST_EPSILON_FLOAT16;
}

function expectArraysPredicate(
    actual: TensorLike, expected: TensorLike,
    predicate: (a: {}, b: {}) => boolean) {
  let checkClassType = true;
  if (isTypedArray(actual) || isTypedArray(expected)) {
    checkClassType = false;
  }
  if (isTypedArray(actual) && isTypedArray(expected)) {
    checkClassType = true;
  }
  if (checkClassType) {
    const aType = actual.constructor.name;
    const bType = expected.constructor.name;

    if (aType !== bType) {
      throw new Error(
          `Arrays are of different type. Actual: ${aType}. ` +
          `Expected: ${bType}`);
    }
  }

  if (Array.isArray(actual) && Array.isArray(expected)) {
    const actualShape = inferShape(actual);
    const expectedShape = inferShape(expected);
    if (!arraysEqual(actualShape, expectedShape)) {
      throw new Error(
          `Arrays have different shapes. ` +
          `Actual: [${actualShape}]. Expected: [${expectedShape}]`);
    }
  }

  const actualFlat =
      isTypedArray(actual) ? actual : flatten(actual as RecursiveArray<number>);
  const expectedFlat = isTypedArray(expected) ?
      expected :
      flatten(expected as RecursiveArray<number>);

  if (actualFlat.length !== expectedFlat.length) {
    throw new Error(
        `Arrays have different lengths actual: ${actualFlat.length} vs ` +
        `expected: ${expectedFlat.length}.\n` +
        `Actual:   ${actualFlat}.\n` +
        `Expected: ${expectedFlat}.`);
  }
  for (let i = 0; i < expectedFlat.length; ++i) {
    const a = actualFlat[i];
    const e = expectedFlat[i];

    if (!predicate(a, e)) {
      throw new Error(
          `Arrays differ: actual[${i}] = ${a}, expected[${i}] = ${e}.\n` +
          `Actual:   ${actualFlat}.\n` +
          `Expected: ${expectedFlat}.`);
    }
  }
  if (typeof expect !== 'undefined') {
    expect().nothing();
  }
}

export interface DoneFn {
  (): void;
  fail: (message?: Error|string) => void;
}

export function expectPromiseToFail(fn: () => Promise<{}>, done: DoneFn): void {
  fn().then(() => done.fail(), () => done());
  if (typeof expect !== 'undefined') {
    expect().nothing();
  }
}

export function expectArraysEqual(actual: TensorLike, expected: TensorLike) {
  const exp = typeof expected === 'string' || typeof expected === 'number' ||
          typeof expected === 'boolean' ?
      [expected] as number[] :
      expected as number[];
  if (isString(actual) || isString((actual as string[])[0]) ||
      isString(expected) || isString((expected as string[])[0])) {
    // tslint:disable-next-line: triple-equals
    return expectArraysPredicate(actual, exp, (a, b) => a == b);
  }
  return expectArraysPredicate(
      actual, expected, (a, b) => areClose(a as number, b as number, 0));
}

export function expectNumbersClose(a: number, e: number, epsilon?: number) {
  if (epsilon == null) {
    epsilon = testEpsilon();
  }
  if (!areClose(a, e, epsilon)) {
    throw new Error(`Numbers differ: actual === ${a}, expected === ${e}`);
  }
  if (typeof expect !== 'undefined') {
    expect().nothing();
  }
}

function areClose(a: number, e: number, epsilon: number): boolean {
  if (!isFinite(a) && !isFinite(e)) {
    return true;
  }
  if (isNaN(a) || isNaN(e) || Math.abs(a - e) > epsilon) {
    return false;
  }
  return true;
}

export function expectValuesInRange(
    actual: TypedArray|number[], low: number, high: number) {
  for (let i = 0; i < actual.length; i++) {
    if (actual[i] < low || actual[i] > high) {
      throw new Error(
          `Value out of range:${actual[i]} low: ${low}, high: ${high}`);
    }
  }
}

export function expectArrayBuffersEqual(
    actual: ArrayBuffer, expected: ArrayBuffer) {
  // Safari does not like comparing ArrayBuffers directly. Wrapping in
  // a Float32Array solves this issue.
  const actualArray = new Float32Array(actual);
  const expectedArray = new Float32Array(expected);
  if (actualArray.length !== expectedArray.length) {
    throw new Error(
        'Expected ArrayBuffer to be of length ' +
        `${expectedArray.length}, but it was ${actualArray.length}`);
  }

  for (let i = 0; i < expectedArray.length; i++) {
    if (actualArray[i] !== expectedArray[i]) {
      throw new Error(
          `Expected ArrayBuffer value at ${i} to be ` +
          `${expectedArray[i]} but got ${actualArray[i]} instead`);
    }
  }
}

/** Encodes strings into utf-8 bytes. */
export function encodeStrings(a: RecursiveArray<{}>):
    RecursiveArray<Uint8Array> {
  for (let i = 0; i < (a as Array<{}>).length; i++) {
    const val = a[i];
    if (Array.isArray(val)) {
      encodeStrings(val);
    } else {
      a[i] = encodeString(val as string);
    }
  }
  return a as RecursiveArray<Uint8Array>;
}

/** Creates an HTMLVideoElement with autoplay-friendly default settings. */
export function createVideoElement(source: HTMLSourceElement):
    Promise<HTMLVideoElement> {
  const video = document.createElement('video');
  if ('playsInline' in video) {
    // tslint:disable-next-line:no-any
    (video as any).playsInline = true;
  }
  video.muted = true;
  video.loop = true;
  video.style.position = 'fixed';
  video.style.left = '0px';
  video.style.top = '0px';

  video.preload = 'auto';
  video.appendChild(source);
  return new Promise(resolve => {
    video.addEventListener('loadeddata', _ => resolve(video));
    video.load();
  });
}

export async function play(video: HTMLVideoElement) {
  await video.play();
  if ('requestVideoFrameCallback' in video) {
    await new Promise(resolve => {
      // tslint:disable-next-line:no-any
      (video as any).requestVideoFrameCallback(resolve);
    });
  }
}
