'use strict;'

let BA = {};

(function(BA) {
const TestPrivateKey = new Uint8Array([
  0xff, 0x1f, 0x47, 0xb1, 0x68, 0xb6, 0xb9, 0xea, 0x65, 0xf7, 0x97,
  0x4f, 0xf2, 0x2e, 0xf2, 0x36, 0x94, 0xe2, 0xf6, 0xb6, 0x8d, 0x66,
  0xf3, 0xa7, 0x64, 0x14, 0x28, 0xd4, 0x45, 0x35, 0x01, 0x8f
]);

const _hpkeModulePromise = import('../third_party/hpke-js/hpke.js');

// Common utilities.

function _get16(buffer, offset) {
  return buffer[offset] << 8 | buffer[offset + 1];
}

function _get32(buffer, offset) {
  return buffer[offset] << 24 | buffer[offset + 1] << 16 |
      buffer[offset + 2] << 8 | buffer[offset + 3];
}

function _put16(buffer, offset, val) {
  buffer[offset] = val >> 8;
  buffer[offset + 1] = val & 0xFF;
}

function _put32(buffer, offset, val) {
  buffer[offset] = (val >> 24) & 0xFF;
  buffer[offset + 1] = (val >> 16) & 0xFF;
  buffer[offset + 2] = (val >> 8) & 0xFF;
  buffer[offset + 3] = val & 0xFF;
}

// Concatenates two Uint8Array's.
function _concat(a, b) {
  let c = new Uint8Array(a.length + b.length);
  for (var i = 0; i < a.length; ++i) {
    c[i] = a[i];
  }
  for (var i = 0; i < b.length; ++i) {
    c[i + a.length] = b[i];
  }
  return c;
}

function _toArrayBuffer(typedArray) {
  return typedArray.buffer.slice(
      typedArray.byteOffset, typedArray.byteOffset + typedArray.byteLength);
}

function _toBytesArrayBuffer(str) {
  return _toArrayBuffer(new TextEncoder().encode(str));
}

function _bufferAsStream(buffer) {
  return new ReadableStream({
    start: controller => {
      controller.enqueue(buffer);
      controller.close();
    }
  });
}

// Returns an ArrayBuffer.
async function _applyTransform(inData, transform) {
  const resultResponse =
      new Response(_bufferAsStream(inData).pipeThrough(transform));
  const resultBlob = await resultResponse.blob();
  return await resultBlob.arrayBuffer();
}

// Returns an ArrayBuffer (promise).
async function _gzip(inData) {
  const compress = new CompressionStream('gzip');
  return _applyTransform(inData, compress);
}

// Returns an ArrayBuffer (promise).
async function _gunzip(inData) {
  const decompress = new DecompressionStream('gzip');
  return _applyTransform(inData, decompress);
}

// InterestGroupData decoding helpers.

function _decodeIgDataHeader(igData) {
  if (igData.length < 8) {
    throw 'Not enough data for B&A and OHTTP headers';
  }
  return {
    version: igData[0],
    keyId: igData[1],
    kemId: _get16(igData, 2),
    kdfId: _get16(igData, 4),
    aeadId: _get16(igData, 6),
    payload: igData.slice(8)
  };
}

// Splits up the actual B&A IG Data into the enc and ct portions
// for HPKE, using `suite` for sizing; and also figures out the appropriate
// info string.
function _splitIgDataPayloadIntoEncAndCt(header, suite) {
  const RequestMessageType = 'message/auction request';

  // From RFC 9458 (Oblivious HTTP):
  // "2.  Build a sequence of bytes (info) by concatenating the ASCII-
  //      encoded string "message/bhttp request"; a zero byte; key_id as an
  //      8-bit integer; plus kem_id, kdf_id, and aead_id as three 16-bit
  //      integers."
  // (except we use a different message type string).
  const infoLength = RequestMessageType.length + 1 + 1 + 6;
  let info = new Uint8Array(infoLength);
  for (let pos = 0; pos < RequestMessageType.length; ++pos) {
    info[pos] = RequestMessageType.charCodeAt(pos);
  }
  info[RequestMessageType.length] = 0;
  info[RequestMessageType.length + 1] = header.keyId;
  _put16(info, RequestMessageType.length + 2, header.kemId);
  _put16(info, RequestMessageType.length + 4, header.kdfId);
  _put16(info, RequestMessageType.length + 6, header.aeadId);
  return {
    info: info,
    enc: header.payload.slice(0, suite.kem.encSize),
    ct: header.payload.slice(suite.kem.encSize)
  };
}

// Unwraps the padding envelope.
function _decodeIgDataPaddingHeader(decryptedText) {
  let length = _get32(decryptedText, 1);
  let format = decryptedText[0];

  // We currently only support format 2, which version = 0, and gzip
  // compression.
  assert_equals(format, 2);
  return {
    format: format,
    data: decryptedText.slice(5, 5 + length)
  };
}

// serverResponse encoding helpers.

// Takes an ArrayBuffer, returns a Uint8Array.
function _frameServerResponse(arrayBuffer) {
  let array = new Uint8Array(arrayBuffer);
  let framedLength = 5 + array.length;
  let framed = new Uint8Array(framedLength);
  framed[0] = 2;  // gzip + ver 0.
  _put32(framed, 1, array.length);
  for (let i = 0; i < array.length; ++i) {
    framed[i + 5] = array[i];
  }
  return framed;
}

async function _encryptServerResponse(payload, decoded) {
  // This again follows RFC 9458 (Oblivious HTTP), "Encapsulation of
  // Responses", just with different message type:
  const ResponseMessageType = 'message/auction response';
  const Nk = decoded.cipherSuite.aead.keySize;
  const Nn = decoded.cipherSuite.aead.nonceSize;
  let secret = await decoded.receiveContext.export(
      _toBytesArrayBuffer(ResponseMessageType), Math.max(Nk, Nn));
  let responseNonce = new Uint8Array(Math.max(Nk, Nn));
  crypto.getRandomValues(responseNonce);
  let salt = _concat(decoded.enc, responseNonce);
  let prk = await decoded.cipherSuite.kdf.extract(salt, secret);
  let aeadKey =
      await decoded.cipherSuite.kdf.expand(prk, _toBytesArrayBuffer('key'), Nk);
  let aeadNonce = await decoded.cipherSuite.kdf.expand(
      prk, _toBytesArrayBuffer('nonce'), Nn);
  let encContext = decoded.cipherSuite.aead.createEncryptionContext(aeadKey);
  let ct = await encContext.seal(
      /*iv=*/ aeadNonce, /*data=*/ payload,
      /*aad=*/ _toBytesArrayBuffer(''));
  return _concat(responseNonce, new Uint8Array(ct));
}

// CBOR requires property names to be in sorted order; but the library we use
// doesn't do it automatically. Since it's easy for a test to fail for the
// wrong reason if the response isn't specified correctly, this ensures the
// proper ordering. It assumes a very simple data model, so no arrays with
// holes, no mixture of different kinds of indices in the map, etc.
// Getting the sort order right in more complicated cases is outside the
// scope of this helper.
function _sortForCbor(input) {
  if (input === null || typeof input !== 'object') {
    return input;
  }

  if (input instanceof Array) {
    let out = [];
    for (let i = 0; i < input.length; ++i) {
      out[i] = _sortForCbor(input[i]);
    }
    return out;
  } else if (input instanceof Uint8Array) {
    return input;
  } else {
    let keys = Object.getOwnPropertyNames(input).sort((a, b) => {
      // CBOR order compares lengths before values.
      if (a.length < b.length)
        return -1;
      if (a.length > b.length)
        return 1;
      if (a < b)
        return -1;
      if (a > b)
        return 1;
      return 0;
    });
    let out = {};
    for (let key of keys) {
      out[key] = _sortForCbor(input[key]);
    }
    return out;
  }
}

// Works on both ArrayBuffer and Uint8Array, returns the same type.
function _injectFault(input) {
  let uint8Input;
  if (input instanceof ArrayBuffer) {
    uint8Input = new Uint8Array(input);
  } else {
    assert_true(input instanceof Uint8Array);
    uint8Input = input;
  }

  // Just mess up the 0th byte.
  uint8Input[0] = uint8Input[0] ^ 0x4e;

  if (input instanceof ArrayBuffer) {
    return _toArrayBuffer(uint8Input);
  } else {
    return uint8Input;
  }
}

// Exported API.

// Decodes the request payload produced by getInterestGroupAdAuctionData into
// {paddedSize: ..., message: ..., cipherSuite: ... , receiveContext: ...,
//  enc:...}
BA.decodeInterestGroupData = async function(igData) {
  const hpke = await _hpkeModulePromise;

  // Decode B&A level headers, and check them.
  const header = _decodeIgDataHeader(igData);

  // Only version 0 in use now.
  assert_equals(header.version, 0);

  // Test config uses keyId = 0x14 only
  // If the feature is not set up properly we may get a different, non-test key.
  // We can't use assert_equals because it includes the (random) non-test key
  // in the error message if testing support for this feature is not present.
  assert_true(header.keyId === 0x14, "valid key Id");

  // Current cipher config.
  assert_equals(header.kemId, hpke.KemId.DhkemX25519HkdfSha256);
  assert_equals(header.kdfId, hpke.KdfId.HkdfSha256);
  assert_equals(header.aeadId, hpke.AeadId.Aes256Gcm);

  const suite = new hpke.CipherSuite({
    kem: header.kemId,
    kdf: header.kdfId,
    aead: header.aeadId,
  });

  // Split up the ciphertext from encapsulated key, and also compute
  // the expected message info.
  const pieces = _splitIgDataPayloadIntoEncAndCt(header, suite);

  // We can now decode the ciphertext.
  const privateKey = await suite.kem.importKey('raw', TestPrivateKey);
  const recipient = await suite.createRecipientContext(
      {recipientKey: privateKey, info: pieces.info, enc: pieces.enc});
  const pt = new Uint8Array(await recipient.open(pieces.ct));

  // The resulting text has yet another envelope with version and size info,
  // and a bunch of padding.
  const withoutPadding = _decodeIgDataPaddingHeader(pt);
  const decoded = CBOR.decode(_toArrayBuffer(withoutPadding.data));

  // Decompress IGs, CBOR-decode them, and replace in-place.
  for (let key of Object.getOwnPropertyNames(decoded.interestGroups)) {
    let val = decoded.interestGroups[key];
    let decompressedVal = await _gunzip(val);
    decoded.interestGroups[key] = CBOR.decode(decompressedVal);
  }

  return {
    paddedSize: pt.length,
    message: decoded,
    receiveContext: recipient,
    cipherSuite: suite,
    enc: pieces.enc
  };
};

BA.injectCborFault = 1;
BA.injectGzipFault = 2;
BA.injectFrameFault = 4;
BA.injectEncryptFault = 8;

// Encodes, compresses, encrypts, etc., `responseObject` into a proper
// serverResponse in reply to `decoded`.
BA.encodeServerResponse =
    async function(responseObject, decoded, injectFaults = 0) {
  let cborPayload = new Uint8Array(CBOR.encode(_sortForCbor(responseObject)));
  if (injectFaults & BA.injectCborFault) {
    cborPayload = _injectFault(cborPayload);
  }

  let gzipPayload = await _gzip(cborPayload);
  if (injectFaults & BA.injectGzipFault) {
    gzipPayload = _injectFault(gzipPayload);
  }

  let framedPayload = _toArrayBuffer(_frameServerResponse(gzipPayload));
  if (injectFaults & BA.injectFrameFault) {
    framedPayload = _injectFault(framedPayload);
  }

  let encrypted = await _encryptServerResponse(framedPayload, decoded);
  if (injectFaults & BA.injectEncryptFault) {
    encrypted = _injectFault(encrypted);
  }

  return encrypted;
};

// Returns a hash string that can be used to authorize a given response,
// formatted for use in an Ad-Auction-Result HTTP header.
BA.payloadHash = async function(serverResponse) {
  let hash =
      new Uint8Array(await crypto.subtle.digest('SHA-256', serverResponse));
  let hashString = ''
  for (let i = 0; i < hash.length; ++i) {
    hashString += String.fromCharCode(hash[i]);
  }
  return btoa(hashString)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/g, '');
};

// Authorizes each serverResponse hash in `hashes` to be used for
// B&A auction result.
BA.authorizeServerResponseHashes = async function(hashes) {
  let authorizeURL =
      new URL('resources/authorize-server-response.py', window.location);
  authorizeURL.searchParams.append('hashes', hashes.join(','));
  await fetch(authorizeURL, {adAuctionHeaders: true});
};

// Authorizes each serverResponse nonce in `nonces` to be used for
// B&A auction result.
BA.authorizeServerResponseNonces = async function(nonces) {
  let authorizeURL =
      new URL('resources/authorize-server-response.py', window.location);
  authorizeURL.searchParams.append('nonces', nonces.join(','));
  await fetch(authorizeURL, {adAuctionHeaders: true});
};

BA.configureCoordinator = async function() {
  // This is async in hope it can eventually use testdriver to configure this.
  return 'https://{{hosts[][]}}';
};

// Runs responseMutator on a minimal correct server response, and expects
// either success/failure based on expectWin.
BA.testWithMutatedServerResponse = async function(
    test, expectWin, responseMutator, igMutator = undefined,
    ownerOverride = null) {
  let finalIgOwner = ownerOverride ? ownerOverride : window.location.origin;
  const uuid = generateUuid(test);
  const adA = createTrackerURL(finalIgOwner, uuid, 'track_get', 'a');
  const adB = createTrackerURL(finalIgOwner, uuid, 'track_get', 'b');
  const adsArray =
      [{renderURL: adA, adRenderId: 'a'}, {renderURL: adB, adRenderId: 'b'}];
  let ig = {ads: adsArray};
  if (igMutator) {
    igMutator(ig, uuid);
  }
  if (ownerOverride !== null) {
    await joinCrossOriginInterestGroup(test, uuid, ownerOverride, ig);
  } else {
    await joinInterestGroup(test, uuid, ig);
  }

  const result = await navigator.getInterestGroupAdAuctionData({
    coordinatorOrigin: await BA.configureCoordinator(),
    seller: window.location.origin
  });
  assert_true(result.requestId !== null);
  assert_true(result.request.length > 0);

  let decoded = await BA.decodeInterestGroupData(result.request);

  let serverResponseMsg = {
    'biddingGroups': {},
    'adRenderURL': ig.ads[0].renderURL,
    'interestGroupName': DEFAULT_INTEREST_GROUP_NAME,
    'interestGroupOwner': finalIgOwner,
  };
  serverResponseMsg.biddingGroups[finalIgOwner] = [0];
  await responseMutator(serverResponseMsg, uuid);

  let serverResponse =
      await BA.encodeServerResponse(serverResponseMsg, decoded);

  let hashString = await BA.payloadHash(serverResponse);
  await BA.authorizeServerResponseHashes([hashString]);

  let auctionResult = await navigator.runAdAuction({
    'seller': window.location.origin,
    'interestGroupBuyers': [finalIgOwner],
    'requestId': result.requestId,
    'serverResponse': serverResponse,
    'resolveToConfig': true,
  });
  if (expectWin) {
    expectSuccess(auctionResult);
    return auctionResult;
  } else {
    expectNoWinner(auctionResult);
  }
};

})(BA);
