'use strict';

// https://www.w3.org/TR/webnn/#enumdef-mloperanddatatype
const allWebNNOperandDataTypes = [
  'float32',
  'float16',
  'int32',
  'uint32',
  'int64',
  'uint64',
  'int8',
  'uint8'
];

// https://webidl.spec.whatwg.org/#idl-unsigned-long
// The unsigned long type is an unsigned integer type that has values in the
// range [0, 4294967295].
// 4294967295 = 2 ** 32 - 1
const kMaxUnsignedLong = 2 ** 32 - 1;

const floatingPointTypes = ['float32', 'float16'];

const signedIntegerTypes = ['int32', 'int64', 'int8'];

const unsignedLongType = 'unsigned long';

const shape0D = [];
const shape1D = [2];
const shape2D = [2, 3];
const shape3D = [2, 3, 4];
const shape4D = [2, 3, 4, 5];
const shape5D = [2, 3, 4, 5, 6];

const adjustOffsetsArray = [
  // Decrease 1
  -1,
  // Increase 1
  1
];

// TODO
// Add more 5+ dimensions
const allWebNNShapesArray =
    [shape0D, shape1D, shape2D, shape3D, shape4D, shape5D];

const notUnsignedLongAxisArray = [
  // String
  'abc',
  // BigInt
  BigInt(100),
  // Object
  {
    value: 1
  },
  // Array Object
  [0, 1],
  // Date Object
  new Date("2024-01-01"),
];

function getRank(inputShape) {
  return inputShape.length;
}

function getAxisArray(inputShape) {
  return Array.from({length: inputShape.length}, (_, i) => i);
}

function getAxesArrayContainSameValues(inputShape) {
  // TODO
  // Currently this function returns an array containing each element which all have the same value.
  // For example axes: [0, 1, 2] for 3D input tensor
  // this function returns
  // [
  //   // two values are same
  //   [0, 0],
  //   [1, 1],
  //   [2, 2],
  //   // three values are same
  //   [0, 0, 0],
  //   [1, 1, 1]
  //   [2, 2, 2]
  // ]
  // while it should return
  // [
  //   // two values are same
  //   [0, 0],
  //   [1, 1],
  //   [2, 2],
  //   [0, 0, 1],
  //   [0, 0, 2],
  //   [0, 1, 0],
  //   [0, 2, 0],
  //   [1, 0, 0],
  //   [2, 0, 0],
  //   [1, 1, 0],
  //   [1, 1, 2],
  //   [1, 0, 1],
  //   [1, 2, 1],
  //   [0, 1, 1],
  //   [2, 1, 1],
  //   [2, 2, 0],
  //   [2, 2, 1],
  //   [2, 0, 2],
  //   [2, 1, 2],
  //   [0, 2, 2],
  //   [1, 2, 2],
  //   // three (all) values are same
  //   [0, 0, 0],
  //   [1, 1, 1]
  //   [2, 2, 2]
  // ]
  const axesArrayContainSameValues = [];
  const length = inputShape.length;
  if (length >= 2) {
    const validAxesArrayFull = getAxisArray(inputShape);
    for (let index = 0; index < length; index++) {
      axesArrayContainSameValues.push(new Array(2).fill(validAxesArrayFull[index]));
      if (length > 2) {
        axesArrayContainSameValues.push(new Array(3).fill(validAxesArrayFull[index]));
      }
    }
  }
  return axesArrayContainSameValues;
}

function generateUnbroadcastableShapes(shape) {
  // Currently this function returns an array of some unbroadcastable shapes.
  // for example given the input shape [2, 3, 4]
  // this function returns
  // [
  //   [3, 3, 4],
  //   [2, 2, 4],
  //   [2, 4, 4],
  //   [2, 3, 3],
  //   [2, 3, 5],
  //   [3],
  //   [5],
  //   [1, 3],
  //   [1, 5],
  //   [1, 1, 3],
  //   [1, 1, 5],
  //   [1, 1, 1, 3],
  //   [1, 1, 1, 5],
  // ]
  if (shape.every(dimension => dimension === 1)) {
    throw new Error(`[${shape}] always can be broadcasted`);
  }
  const resultShapes = [];
  const length = shape.length;
  if (!shape.slice(0, length - 1).every(dimension => dimension === 1)) {
    for (let i = 0; i < length; i++) {
      if (shape[i] !== 1) {
        for (let offset of [-1, 1]) {
          const shapeB = shape.slice();
          shapeB[i] += offset;
          if (shapeB[i] !== 1) {
            resultShapes.push(shapeB);
          }
        }
      }
    }
  }
  const lastDimensionSize = shape[length - 1];
  if (lastDimensionSize !== 1) {
    for (let j = 0; j <= length; j++) {
      if (lastDimensionSize > 2) {
        resultShapes.push(Array(j).fill(1).concat([lastDimensionSize - 1]));
      }
      resultShapes.push(Array(j).fill(1).concat([lastDimensionSize + 1]));
    }
  }
  return resultShapes;
}

function generateOutOfRangeValuesArray(type) {
  let range, outsideValueArray;
  switch (type) {
    case 'unsigned long':
      range = [0, kMaxUnsignedLong];
      break;
    default:
      throw new Error(`Unsupport ${type}`);
  }
  outsideValueArray = [range[0] - 1, range[1] + 1];
  return outsideValueArray;
}

let inputIndex = 0;
let inputAIndex = 0;
let inputBIndex = 0;
let context;

test(() => assert_not_equals(navigator.ml, undefined, "ml property is defined on navigator"));

promise_setup(async () => {
  if (navigator.ml === undefined) {
    return;
  }
  const deviceType = location.search.substring(1);
  context = await navigator.ml.createContext({deviceType: deviceType});
}, {explicit_timeout: true});

function assert_throws_with_label(func, regrexp) {
  try {
    func.call(this);
    assert_true(false, 'Graph builder method unexpectedly succeeded');
  } catch (e) {
    assert_equals(e.name, 'TypeError');
    const error_message = e.message;
    assert_not_equals(error_message.match(regrexp), null);
  }
}

function validateTwoInputsBroadcastable(operationName, label) {
  if (navigator.ml === undefined) {
    return;
  }
  promise_test(async t => {
    const builder = new MLGraphBuilder(context);
    for (let dataType of allWebNNOperandDataTypes) {
      if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
        assert_throws_js(
            TypeError,
            () => builder.input(
                `inputA${++inputAIndex}`, {dataType, shape: shape1D}));
        continue;
      }
      for (let shape of allWebNNShapesArray) {
        if (shape.length > 0) {
          const inputA =
              builder.input(`inputA${++inputAIndex}`, {dataType, shape});
          const unbroadcastableShapes = generateUnbroadcastableShapes(shape);
          for (let shape of unbroadcastableShapes) {
            const inputB =
                builder.input(`inputB${++inputBIndex}`, {dataType, shape});
            assert_equals(typeof builder[operationName], 'function');
            const options = {label};
            const regrexp = new RegExp('\\[' + label + '\\]');
            assert_throws_with_label(
                () => builder[operationName](inputA, inputB, options), regrexp);
            assert_throws_with_label(
                () => builder[operationName](inputB, inputA, options), regrexp);
          }
        }
      }
    }
  }, `[${operationName}] TypeError is expected if two inputs aren't broadcastable`);
}

function validateTwoBroadcastableInputsTensorLimit(operationName, label) {
  if (navigator.ml === undefined) {
    return;
  }
  promise_test(async t => {
    const builder = new MLGraphBuilder(context);

    const a = builder.input('a', {dataType: 'float32',
        shape: [context.opSupportLimits().maxTensorByteLength / 4, 1]});
    const b = builder.input('b', {dataType: 'float32', shape: [1, 5] });

    const options = {label};
    const regrexp = new RegExp('\\[' + label + '\\]');
    assert_throws_with_label(
        () => builder[operationName](a, b, options), regrexp);
  }, `[${operationName}] throw if the output tensor byte length exceeds limit`);
}

function validateTwoInputsOfSameDataType(operationName, label) {
  if (navigator.ml === undefined) {
    return;
  }
  let operationNameArray;
  if (typeof operationName === 'string') {
    operationNameArray = [operationName];
  } else if (Array.isArray(operationName)) {
    operationNameArray = operationName;
  } else {
    throw new Error(`${operationName} should be an operation name string or an operation name string array`);
  }
  for (let subOperationName of operationNameArray) {
    promise_test(async t => {
      const builder = new MLGraphBuilder(context);
      for (let dataType of allWebNNOperandDataTypes) {
        if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
          assert_throws_js(
              TypeError,
              () => builder.input(
                  `inputA${++inputAIndex}`, {dataType, shape: shape1D}));
          continue;
        }
        for (let shape of allWebNNShapesArray) {
          const inputA =
              builder.input(`inputA${++inputAIndex}`, {dataType, shape});
          for (let dataTypeB of allWebNNOperandDataTypes) {
            if (!context.opSupportLimits().input.dataTypes.includes(
                    dataTypeB)) {
              assert_throws_js(
                  TypeError,
                  () => builder.input(
                      `inputB${++inputBIndex}`, {dataTypeB, shape1D}));
              continue;
            }
            if (dataType !== dataTypeB) {
              const inputB = builder.input(
                  `inputB${++inputBIndex}`, {dataType: dataTypeB, shape});
              const options = {label};
              const regrexp = new RegExp('\\[' + label + '\\]');
              assert_equals(typeof builder[subOperationName], 'function');
              assert_throws_with_label(
                  () => builder[subOperationName](inputA, inputB, options),
                  regrexp);
            }
          }
        }
      }
    }, `[${subOperationName}] TypeError is expected if two inputs aren't of same data type`);
  }
}

/**
 * Validate options.axes by given operation and input rank for
 * argMin/Max / layerNormalization / Reduction operations operations
 * @param {(String[]|String)} operationName - An operation name array or an
 *     operation name
 */
function validateOptionsAxes(operationName) {
  if (navigator.ml === undefined) {
    return;
  }
  let operationNameArray;
  if (typeof operationName === 'string') {
    operationNameArray = [operationName];
  } else if (Array.isArray(operationName)) {
    operationNameArray = operationName;
  } else {
    throw new Error(`${operationName} should be an operation name string or an operation name string array`);
  }
  const invalidAxisArray = generateOutOfRangeValuesArray(unsignedLongType);
  for (let subOperationName of operationNameArray) {
    // TypeError is expected if any of options.axes elements is not an unsigned long interger
    promise_test(async t => {
      const builder = new MLGraphBuilder(context);
      for (let dataType of allWebNNOperandDataTypes) {
        if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
          assert_throws_js(
              TypeError,
              () => builder.input(
                  `inputA${++inputAIndex}`, {dataType, shape: shape1D}));
          continue;
        }
        for (let shape of allWebNNShapesArray) {
          const rank = getRank(shape);
          if (rank >= 1) {
            const input =
                builder.input(`input${++inputIndex}`, {dataType, shape});
            for (let invalidAxis of invalidAxisArray) {
              assert_equals(typeof builder[subOperationName], 'function');
              assert_throws_js(
                  TypeError,
                  () => builder[subOperationName](input, {axes: invalidAxis}));
            }
            for (let axis of notUnsignedLongAxisArray) {
              assert_false(
                  typeof axis === 'number' && Number.isInteger(axis),
                  `[${subOperationName}] any of options.axes elements should be of 'unsigned long'`);
              assert_equals(typeof builder[subOperationName], 'function');
              assert_throws_js(
                  TypeError,
                  () => builder[subOperationName](input, {axes: [axis]}));
            }
          }
        }
      }
    }, `[${subOperationName}] TypeError is expected if any of options.axes elements is not an unsigned long interger`);

    // TypeError is expected if any of options.axes elements is greater or equal
    // to the size of input
    promise_test(async t => {
      const builder = new MLGraphBuilder(context);
      for (let dataType of allWebNNOperandDataTypes) {
        if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
          assert_throws_js(
              TypeError,
              () => builder.input(
                  `inputA${++inputAIndex}`, {dataType, shape: shape1D}));
          continue;
        }
        for (let shape of allWebNNShapesArray) {
          const rank = getRank(shape);
          if (rank >= 1) {
            const input =
                builder.input(`input${++inputIndex}`, {dataType, shape});
            assert_equals(typeof builder[subOperationName], 'function');
            assert_throws_js(
                TypeError,
                () => builder[subOperationName](input, {axes: [rank]}));
            assert_throws_js(
                TypeError,
                () => builder[subOperationName](input, {axes: [rank + 1]}));
          }
        }
      }
    }, `[${subOperationName}] TypeError is expected if any of options.axes elements is greater or equal to the size of input`);

    // TypeError is expected if two or more values are same in the axes sequence
    promise_test(async t => {
      const builder = new MLGraphBuilder(context);
      for (let dataType of allWebNNOperandDataTypes) {
        if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
          assert_throws_js(
              TypeError,
              () => builder.input(
                  `inputA${++inputAIndex}`, {dataType, shape: shape1D}));
          continue;
        }
        for (let shape of allWebNNShapesArray) {
          const rank = getRank(shape);
          if (rank >= 2) {
            const input =
                builder.input(`input${++inputIndex}`, {dataType, shape});
            const axesArrayContainSameValues =
                getAxesArrayContainSameValues(shape);
            for (let axes of axesArrayContainSameValues) {
              assert_equals(typeof builder[subOperationName], 'function');
              assert_throws_js(
                  TypeError, () => builder[subOperationName](input, {axes}));
            }
          }
        }
      }
    }, `[${subOperationName}] TypeError is expected if two or more values are same in the axes sequence`);
  }
}

// TODO: remove this method once all the data type limits of the unary
// operations are specified in context.OpSupportLimits().
/**
 * Validate a unary operation
 * @param {String} operationName - An operation name
 * @param {Array} supportedDataTypes - Test building with these data types
 *     succeeds and test building with all other data types fails
 */
function validateUnaryOperation(operationName, supportedDataTypes, label) {
  promise_test(async t => {
    const builder = new MLGraphBuilder(context);
    for (let dataType of supportedDataTypes) {
      if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
        assert_throws_js(
            TypeError,
            () => builder.input(
                `inputA${++inputAIndex}`, {dataType, shape: shape1D}));
        continue;
      }
      for (let shape of allWebNNShapesArray) {
        const input = builder.input(`input`, {dataType, shape});
        assert_equals(typeof builder[operationName], 'function');
        const output = builder[operationName](input);
        assert_equals(output.dataType, dataType);
        assert_array_equals(output.shape, shape);
      }
    }
  }, `[${operationName}] Test building an unary operator with supported type.`);

  const unsupportedDataTypes =
      new Set(allWebNNOperandDataTypes).difference(new Set(supportedDataTypes));
  promise_test(async t => {
    const builder = new MLGraphBuilder(context);
    for (let dataType of unsupportedDataTypes) {
      if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
        assert_throws_js(
            TypeError,
            () => builder.input(
                `inputA${++inputAIndex}`, {dataType, shape: shape1D}));
        continue;
      }
      for (let shape of allWebNNShapesArray) {
        const input = builder.input(`input`, {dataType, shape});
        assert_equals(typeof builder[operationName], 'function');
        const options = {label};
        const regrexp = new RegExp('\\[' + label + '\\]');
        assert_throws_with_label(
            () => builder[operationName](input, options), regrexp);
      }
    }
  }, `[${operationName}] Throw if the dataType is not supported for an unary operator.`);
}

/**
 * Validate a single input operation
 * @param {String} operationName - An operation name
 */
function validateSingleInputOperation(operationName, label) {
  promise_test(async t => {
    const builder = new MLGraphBuilder(context);
    const supportedDataTypes =
        context.opSupportLimits()[operationName].input.dataTypes;
    for (let dataType of supportedDataTypes) {
      if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
        continue;
      }
      for (let shape of allWebNNShapesArray) {
        const input = builder.input(`input`, {dataType, shape});
        const output = builder[operationName](input);
        assert_equals(output.dataType, dataType);
        assert_array_equals(output.shape, shape);
      }
    }
  }, `[${operationName}] Test building the operator with supported data type.`);

  promise_test(async t => {
    const builder = new MLGraphBuilder(context);
    const unsupportedDataTypes =
        new Set(allWebNNOperandDataTypes)
            .difference(new Set(
                context.opSupportLimits()[operationName].input.dataTypes));
    for (let dataType of unsupportedDataTypes) {
      if (!context.opSupportLimits().input.dataTypes.includes(dataType)) {
        assert_throws_js(
            TypeError,
            () => builder.input(
                `inputA${++inputAIndex}`, {dataType, shape: shape1D}));
        continue;
      }
      for (let shape of allWebNNShapesArray) {
        const input = builder.input(`input`, {dataType, shape});
        assert_equals(typeof builder[operationName], 'function');
        const options = {label};
        const regrexp = new RegExp('\\[' + label + '\\]');
        assert_throws_with_label(
            () => builder[operationName](input, options), regrexp);
      }
    }
  }, `[${operationName}] Throw if the data type is not supported for the operator.`);
}

/**
 * Basic test that the builder method specified by `operationName` throws if
 * given an input from another builder. Operands which do not accept a float32
 * square 2D input should pass their own `operatorDescriptor`.
 * @param {String} operationName
 * @param {String} operatorDescriptor
 */
function validateInputFromAnotherBuilder(operatorName, operatorDescriptor = {
  dataType: 'float32',
  shape: [2, 2]
}) {
  multi_builder_test(async (t, builder, otherBuilder) => {
    const inputFromOtherBuilder =
        otherBuilder.input('input', operatorDescriptor);
    assert_equals(typeof builder[operatorName], 'function');
    assert_throws_js(
        TypeError, () => builder[operatorName](inputFromOtherBuilder));
  }, `[${operatorName}] throw if input is from another builder`);
};

/**
 * Basic test that the builder method specified by `operationName` throws if one
 * of its inputs is from another builder. This helper may only be used by
 * operands which accept float32 square 2D inputs.
 * @param {String} operationName
 */
function validateTwoInputsFromMultipleBuilders(operatorName) {
  const opDescriptor = {dataType: 'float32', shape: [2, 2]};

  multi_builder_test(async (t, builder, otherBuilder) => {
    const inputFromOtherBuilder = otherBuilder.input('other', opDescriptor);

    const input = builder.input('input', opDescriptor);
    assert_equals(typeof builder[operatorName], 'function');
    assert_throws_js(
        TypeError, () => builder[operatorName](inputFromOtherBuilder, input));
  }, `[${operatorName}] throw if first input is from another builder`);

  multi_builder_test(async (t, builder, otherBuilder) => {
    const inputFromOtherBuilder = otherBuilder.input('other', opDescriptor);

    const input = builder.input('input', opDescriptor);
    assert_equals(typeof builder[operatorName], 'function');
    assert_throws_js(
        TypeError, () => builder[operatorName](input, inputFromOtherBuilder));
  }, `[${operatorName}] throw if second input is from another builder`);
};

function multi_builder_test(func, description) {
  promise_test(async t => {
    const context = await navigator.ml.createContext();

    const builder = new MLGraphBuilder(context);
    const otherBuilder = new MLGraphBuilder(context);

    await func(t, builder, otherBuilder);
  }, description);
}
