const STORE_URL = '/fenced-frame/resources/key-value-store.py';
const BEACON_URL = '/fenced-frame/resources/beacon-store.py';
const REMOTE_EXECUTOR_URL = '/fenced-frame/resources/remote-context-executor.https.html';

// If your test needs to modify FLEDGE bidding or decision logic, you should
// update the generated JS in the corresponding handler below.
const FLEDGE_BIDDING_URL = '/fenced-frame/resources/fledge-bidding-logic.py';
const FLEDGE_DECISION_URL = '/fenced-frame/resources/fledge-decision-logic.py';

// Creates a URL that includes a list of stash key UUIDs that are being used
// in the test. This allows us to generate UUIDs on the fly and let anything
// (iframes, fenced frames, pop-ups, etc...) that wouldn't have access to the
// original UUID variable know what the UUIDs are.
// @param {string} href - The base url of the page being navigated to
// @param {string list} keylist - The list of key UUIDs to be used. Note that
//                                order matters when extracting the keys
function generateURL(href, keylist) {
  const ret_url = new URL(href, location.href);
  ret_url.searchParams.append("keylist", keylist.join(','));
  return ret_url;
}

function getRemoteContextURL(origin) {
  return new URL(REMOTE_EXECUTOR_URL, origin);
}

async function runSelectRawURL(
    href, resolve_to_config = false, register_beacon = false) {
  try {
    await sharedStorage.worklet.addModule(
      "/shared-storage/resources/simple-module.js");
  } catch (e) {
    // Shared Storage needs to have a module added before we can operate on it.
    // It is generated on the fly with this call, and since there's no way to
    // tell through the API if a module already exists, wrap the addModule call
    // in a try/catch so that if it runs a second time in a test, it will
    // gracefully fail rather than bring the whole test down.
  }
  let operation = {url: href};
  if (register_beacon) {
    operation.reportingMetadata = {
      'reserved.top_navigation_start':
          BEACON_URL + '?type=reserved.top_navigation_start',
      'reserved.top_navigation_commit':
          BEACON_URL + '?type=reserved.top_navigation_commit',
    };
  }
  return await sharedStorage.selectURL(
      'test-url-selection-operation', [operation], {
        data: {'mockResult': 0},
        resolveToConfig: resolve_to_config,
        keepAlive: true,
      });
}

// Similar to generateURL, but creates
// 1. An urn:uuid if `resolve_to_config` is false.
// 2. A fenced frame config object if `resolve_to_config` is true.
// This relies on a mock Shared Storage auction, since it is the simplest
// WP-exposed way to turn a url into an urn:uuid or a fenced frame config.
// Note: this function, unlike generateURL, is asynchronous and needs to be
// called with an await operator.
// @param {string} href - The base url of the page being navigated to
// @param {string list} keylist - The list of key UUIDs to be used. Note that
//                                order matters when extracting the keys
// @param {boolean} [resolve_to_config = false] - Determines whether the result
//                                                of `sharedStorage.selectURL()`
//                                                is an urn:uuid or a fenced
//                                                frame config.
// Note:
// 1. There is a limit of 3 calls per origin per pageload for
// `sharedStorage.selectURL()`, so `runSelectURL()` must also respect this
// limit.
// 2. If `resolve_to_config` is true, blink feature `FencedFramesAPIChanges`
// needs to be enabled for `selectURL()` to return a fenced frame config.
// Otherwise `selectURL()` will fall back to the old behavior that returns an
// urn:uuid.
async function runSelectURL(
    href, keylist = [], resolve_to_config = false, register_beacon = false) {
  const full_url = generateURL(href, keylist);
  return await runSelectRawURL(full_url, resolve_to_config, register_beacon);
}

async function generateURNFromFledgeRawURL(
    href, nested_urls, resolve_to_config = false, ad_with_size = false,
    requested_size = null, register_beacon = false) {
  const bidding_token = token();
  const seller_token = token();

  const ad_components_list = nested_urls.map((url) => {
    return ad_with_size ?
      { renderURL: url, sizeGroup: "group1" } :
      { renderURL: url }
  });

  let interestGroup = {
    name: 'testAd1',
    owner: location.origin,
    biddingLogicURL: new URL(FLEDGE_BIDDING_URL, location.origin),
    ads: [{
      renderURL: href,
      bid: 1,
      allowedReportingOrigins: [location.origin],
    }],
    userBiddingSignals: {biddingToken: bidding_token},
    trustedBiddingSignalsKeys: ['key1'],
    adComponents: ad_components_list,
  };

  let biddingURLParams =
      new URLSearchParams(interestGroup.biddingLogicURL.search);
  if (requested_size)
    biddingURLParams.set(
        'requested-size', requested_size[0] + '-' + requested_size[1]);
  if (ad_with_size)
    biddingURLParams.set('ad-with-size', 1);
  if (register_beacon)
    biddingURLParams.set('beacon', 1);
  interestGroup.biddingLogicURL.search = biddingURLParams;

  if (ad_with_size) {
    interestGroup.ads[0].sizeGroup = 'group1';
    interestGroup.adSizes = {'size1': {width: '100px', height: '50px'}};
    interestGroup.sizeGroups = {'group1': ['size1']};
  }

  // Pick an arbitrarily high duration to guarantee that we never leave the
  // ad interest group while the test runs.
  navigator.joinAdInterestGroup(interestGroup, /*durationSeconds=*/3000000);

  let auctionConfig = {
    seller: location.origin,
    interestGroupBuyers: [location.origin],
    decisionLogicURL: new URL(FLEDGE_DECISION_URL, location.origin),
    auctionSignals: {biddingToken: bidding_token, sellerToken: seller_token},
    resolveToConfig: resolve_to_config
  };

  if (requested_size) {
    let decisionURLParams =
      new URLSearchParams(auctionConfig.decisionLogicURL.search);
    decisionURLParams.set(
        'requested-size', requested_size[0] + '-' + requested_size[1]);
    auctionConfig.decisionLogicURL.search = decisionURLParams;

    auctionConfig['requestedSize'] = {width: requested_size[0], height: requested_size[1]};
  }

  return navigator.runAdAuction(auctionConfig);
}

// Similar to runSelectURL, but uses FLEDGE instead of Shared Storage as the
// auctioning tool.
// Note: this function, unlike generateURL, is asynchronous and needs to be
// called with an await operator. @param {string} href - The base url of the
// page being navigated to @param {string list} keylist - The list of key UUIDs
// to be used. Note that order matters when extracting the keys
// @param {string} href - The base url of the page being navigated to
// @param {string list} keylist - The list of key UUIDs to be used. Note that
//                                order matters when extracting the keys
// @param {string list} nested_urls - A list of urls that will eventually become
//                                    the nested configs/ad components
// @param {boolean} [resolve_to_config = false] - Determines whether the result
//                                                of `navigator.runAdAuction()`
//                                                is an urn:uuid or a fenced
//                                                frame config.
// @param {boolean} [ad_with_size = false] - Determines whether the auction is
//                                           run with ad sizes specified.
// @param {boolean} [register_beacon = false] - If true, FLEDGE logic will
//                                              register reporting beacons after
//                                              completion.
async function generateURNFromFledge(
    href, keylist, nested_urls = [], resolve_to_config = false,
    ad_with_size = false, requested_size = null, register_beacon = false) {
  const full_url = generateURL(href, keylist);
  return generateURNFromFledgeRawURL(
      full_url, nested_urls, resolve_to_config, ad_with_size, requested_size,
      register_beacon);
}

// Extracts a list of UUIDs from the from the current page's URL.
// @returns {string list} - The list of UUIDs extracted from the page. This can
//                          be read into multiple variables using the
//                          [key1, key2, etc...] = parseKeyList(); pattern.
function parseKeylist() {
  const url = new URL(location.href);
  const keylist = url.searchParams.get("keylist");
  return keylist.split(',');
}

// Converts a same-origin URL to a cross-origin URL
// @param {URL} url - The URL object whose origin is being converted
// @param {boolean} [https=true] - Whether or not to use the HTTPS origin
//
// @returns {URL} The new cross-origin URL
function getRemoteOriginURL(url, https=true) {
  const same_origin = location.origin;
  const cross_origin = https ? get_host_info().HTTPS_REMOTE_ORIGIN
      : get_host_info().HTTP_REMOTE_ORIGIN;
  return new URL(url.toString().replace(same_origin, cross_origin));
}

// Builds a URL to be used as a remote context executor.
function generateRemoteContextURL(headers, origin) {
  // Generate the unique id for the parent/child channel.
  const uuid = token();

  // Use the absolute path of the remote context executor source file, so that
  // nested contexts will work.
  const url = getRemoteContextURL(origin ? origin : location.origin);
  url.searchParams.append('uuid', uuid);

  // Add the header to allow loading in a fenced frame.
  headers.push(["Supports-Loading-Mode", "fenced-frame"]);

  // Transform the headers into the expected format.
  // https://web-platform-tests.org/writing-tests/server-pipes.html#headers
  function escape(s) {
    return s.replace('(', '\\(').replace(')', '\\)').replace(',', '\\,');
  }
  const formatted_headers = headers.map((header) => {
    return `header(${escape(header[0])}, ${escape(header[1])})`;
  });
  url.searchParams.append('pipe', formatted_headers.join('|'));

  return [uuid, url];
}

function buildRemoteContextForObject(object, uuid, html) {
  // https://github.com/web-platform-tests/wpt/blob/master/common/dispatcher/README.md
  const context = new RemoteContext(uuid);
  if (html) {
    context.execute_script(
      (html_source) => {
        document.body.insertAdjacentHTML('beforebegin', html_source);
      },
    [html]);
  }

  // We need a little bit of boilerplate in the handlers because Proxy doesn't
  // work so nicely with HTML elements.
  const handler = {
    get: (target, key) => {
      if (key == "execute") {
        return context.execute_script;
      }
      if (key == "element") {
        return object;
      }
      if (key in target) {
        return target[key];
      }
      return context[key];
    },
    set: (target, key, value) => {
      target[key] = value;
      return value;
    }
  };

  // If `object` is null (e.g. a window created with noopener), set it to a
  // dummy value so that the Proxy constructor won't fail.
  if (object == null) {
    object = {};
  }
  const proxy = new Proxy(object, handler);
  return proxy;
}

// Attaches an object that waits for scripts to execute from RemoteContext.
// (In practice, this is either a frame or a window.)
// Returns a proxy for the object that first resolves to the object itself,
// then resolves to the RemoteContext if the property isn't found.
// The proxy also has an extra attribute `execute`, which is an alias for the
// remote context's `execute_script(fn, args=[])`.
function attachContext(object_constructor, html, headers, origin) {
  const [uuid, url] = generateRemoteContextURL(headers, origin);
  const object = object_constructor(url);
  return buildRemoteContextForObject(object, uuid, html);
}

// TODO(crbug.com/1347953): Update this function to also test
// `sharedStorage.selectURL()` that returns a fenced frame config object.
// This should be done after fixing the following flaky tests that use this
// function.
// 1. crbug.com/1372536: resize-lock-input.https.html
// 2. crbug.com/1394559: unfenced-top.https.html
async function attachOpaqueContext(
    generator_api, resolve_to_config, ad_with_size, requested_size,
    register_beacon, object_constructor, html, headers, origin,
    component_origin, num_components) {
  const [uuid, url] = generateRemoteContextURL(headers, origin);

  let components_list = [];
  for (let i = 0; i < num_components; i++) {
    let [component_uuid, component_url] =
        generateRemoteContextURL(headers, component_origin);
    // This field will be read by attachComponentFrameContext() in order to
    // know what uuid to point to when building the remote context.
    html += '<input type=\'hidden\' id=\'component_uuid_' + i + '\' value=\'' +
        component_uuid + '\'>';
    components_list.push(component_url);
  }

  const id = await (
      generator_api == 'fledge' ?
          generateURNFromFledge(
              url, [], components_list, resolve_to_config, ad_with_size,
              requested_size, register_beacon) :
          runSelectURL(url, [], resolve_to_config, register_beacon));
  const object = object_constructor(id);
  return buildRemoteContextForObject(object, uuid, html);
}

function attachPotentiallyOpaqueContext(
    generator_api, resolve_to_config, ad_with_size, requested_size,
    register_beacon, frame_constructor, html, headers, origin,
    component_origin, num_components) {
  generator_api = generator_api.toLowerCase();
  if (generator_api == 'fledge' || generator_api == 'sharedstorage') {
    return attachOpaqueContext(
        generator_api, resolve_to_config, ad_with_size, requested_size,
        register_beacon, frame_constructor, html, headers, origin,
        component_origin, num_components);
  } else {
    return attachContext(frame_constructor, html, headers, origin);
  }
}

function attachFrameContext(
    element_name, generator_api, resolve_to_config, ad_with_size,
    requested_size, register_beacon, html, headers, attributes, origin,
    component_origin, num_components) {
  frame_constructor = (id) => {
    frame = document.createElement(element_name);
    attributes.forEach(attribute => {
      frame.setAttribute(attribute[0], attribute[1]);
    });
    if (element_name == "iframe") {
      frame.src = id;
    } else if (id instanceof FencedFrameConfig) {
      frame.config = id;
    } else {
      const config = new FencedFrameConfig(id);
      frame.config = config;
    }
    document.body.append(frame);
    return frame;
  };
  return attachPotentiallyOpaqueContext(
      generator_api, resolve_to_config, ad_with_size, requested_size,
      register_beacon, frame_constructor, html, headers, origin,
      component_origin, num_components);
}

// Performs a content-initiated navigation of a frame proxy. This navigated page
// uses a new urn:uuid as its communication channel to prevent potential clashes
// with the currently loaded document.
async function navigateFrameContext(frame_proxy, {headers = [], origin = ''}) {
  const [uuid, url] = generateRemoteContextURL(headers, origin);
  frame_proxy.execute((url) => {
    window.executor.suspend(() => {
      window.location = url;
    });
  }, [url])
  frame_proxy.context_id = uuid;
}

function replaceFrameContext(frame_proxy, {
  generator_api = '',
  resolve_to_config = false,
  ad_with_size = false,
  requested_size = null,
  register_beacon = false,
  html = '',
  headers = [],
  origin = ''
} = {}) {
  frame_constructor = (id) => {
    if (frame_proxy.element.nodeName == "IFRAME") {
      frame_proxy.element.src = id;
    } else if (id instanceof FencedFrameConfig) {
      frame_proxy.element.config = id;
    } else {
      const config = new FencedFrameConfig(id);
      frame_proxy.element.config = config;
    }
    return frame_proxy.element;
  };
  return attachPotentiallyOpaqueContext(
      generator_api, resolve_to_config, ad_with_size, requested_size,
      register_beacon, frame_constructor, html, headers, origin);
}

// Attach a fenced frame that waits for scripts to execute. Takes as input a(n
// optional) dictionary of configs:
// - generator_api: the name of the API that should generate the urn/config.
//    Supports (case-insensitive) "fledge" and "sharedstorage", or any other
//    value as a default. If you generate a urn, then you need to await the
//    result of this function.
// - resolve_to_config: whether a config should be used. (currently only works
//    for FLEDGE and sharedStorage generator_api)
// - ad_with_size: whether an ad auction is run with size specified for the ads
//    and ad components. (currently only works for FLEDGE)
// - requested_size: A 2-element list with the width and height for
//    requestedSize in the FLEDGE auction config. This is different from
//    ad_with_size, which refers to size information provided alongside the ads
//    themselves.
// - register_beacon: If true and generator_api = "fledge", an automatic beacon
//    and a destination URL reportEvent() beacon will be registered after the
//    FLEDGE auction completes.
// - html: extra HTML source code to inject into the loaded frame
// - headers: an array of header pairs [[key, value], ...]
// - attributes: an array of attribute pairs to set on the frame [[key, value],
//    ...]
// - origin: origin of the url, default to location.origin if not set. Returns a
//   proxy that acts like the frame HTML element, but with an extra function
//   `execute`. See `attachFrameContext` or the README for more details.
function attachFencedFrameContext({
  generator_api = '',
  resolve_to_config = false,
  ad_with_size = false,
  requested_size = null,
  register_beacon = false,
  html = '',
  headers = [],
  attributes = [],
  origin = '',
  component_origin = '',
  num_components = 0
} = {}) {
  return attachFrameContext(
      'fencedframe', generator_api, resolve_to_config, ad_with_size,
      requested_size, register_beacon, html, headers, attributes, origin,
      component_origin, num_components);
}

// Attach an iframe that waits for scripts to execute.
// See `attachFencedFrameContext` for more details.
function attachIFrameContext({
  generator_api = '',
  register_beacon = false,
  html = '',
  headers = [],
  attributes = [],
  origin = '',
  component_origin = '',
  num_components = 0
} = {}) {
  return attachFrameContext(
      'iframe', generator_api, resolve_to_config = false, ad_with_size = false,
      requested_size = null, register_beacon, html, headers, attributes, origin,
      component_origin, num_components);
}

// Open a window that waits for scripts to execute.
// Returns a proxy that acts like the window object, but with an extra
// function `execute`. See `attachContext` for more details.
function attachWindowContext({target="_blank", html="", headers=[], origin=""}={}) {
  window_constructor = (url) => {
    return window.open(url, target);
  }

  return attachContext(window_constructor, html, headers, origin);
}

// Attaches an ad component in a fenced frame. For this to work, this must be
// called in a frame that was generated with attachFrameContext() using the
// Protected Audience API (generator_api: 'fledge').
function attachComponentFencedFrameContext(
    index = 0, {attributes = [], html = ''} = {}) {
  const urn = window.fence.getNestedConfigs()[index];
  return attachComponentFrameContext(
      index, 'fencedframe', urn, attributes, html);
}

// Same as attachComponentFencedFrameContext, but in a urn iframe.
function attachComponentIFrameContext(
    index = 0, {attributes = [], html = ''} = {}) {
  const urn = navigator.adAuctionComponents(index + 1)[index];
  return attachComponentFrameContext(index, 'iframe', urn, attributes, html);
}

function attachComponentFrameContext(
    index, element_name, urn, attributes, html) {
  assert_not_equals(
      document.getElementById('component_uuid_' + index), null,
      'Component frames can only be attached to frames loaded with ' +
          'attach*FrameContext() with `num_components` set to at least ' +
          (index + 1) + '.');

  let frame = document.createElement(element_name);
  attributes.forEach(attribute => {
    frame.setAttribute(attribute[0], attribute[1]);
  });
  if (element_name == 'iframe') {
    frame.src = urn;
  } else {
    frame.config = urn;
  }
  document.body.append(frame);
  const context_uuid = document.getElementById('component_uuid_' + index).value;
  return buildRemoteContextForObject(frame, context_uuid, html);
}

// Converts a key string into a key uuid using a cryptographic hash function.
// This function only works in secure contexts (HTTPS).
async function stringToStashKey(string) {
  // Compute a SHA-256 hash of the input string, and convert it to hex.
  const data = new TextEncoder().encode(string);
  const digest = await crypto.subtle.digest('SHA-256', data);
  const digest_array = Array.from(new Uint8Array(digest));
  const digest_as_hex = digest_array.map(b => b.toString(16).padStart(2, '0')).join('');

  // UUIDs are structured as 8X-4X-4X-4X-12X.
  // Use the first 32 hex digits and ignore the rest.
  const digest_slices = [digest_as_hex.slice(0,8),
                         digest_as_hex.slice(8,12),
                         digest_as_hex.slice(12,16),
                         digest_as_hex.slice(16,20),
                         digest_as_hex.slice(20,32)];
  return digest_slices.join('-');
}

// Create a fenced frame. Then navigate it using the given `target`, which can
// be either an urn:uuid or a fenced frame config object.
function attachFencedFrame(target) {
  if (window.test_driver) {
    assert_implements(
        window.HTMLFencedFrameElement,
        'The HTMLFencedFrameElement should be exposed on the window object');
  }

  const fenced_frame = document.createElement('fencedframe');

  if (target instanceof FencedFrameConfig) {
    fenced_frame.config = target;
  } else {
    const config = new FencedFrameConfig(target);
    fenced_frame.config = config;
  }

  document.body.append(fenced_frame);
  return fenced_frame;
}

function attachIFrame(url) {
  const iframe = document.createElement('iframe');
  iframe.src = url;
  document.body.append(iframe);
  return iframe;
}

// Reads the value specified by `key` from the key-value store on the server.
async function readValueFromServer(key) {
  // Resolve the key if it is a Promise.
  key = await key;

  const serverURL = `${STORE_URL}?key=${key}`;
  const response = await fetch(serverURL);
  if (!response.ok)
    throw new Error('An error happened in the server');
  const value = await response.text();

  // The value is not stored in the server.
  if (value === "<Not set>")
    return { status: false };

  return { status: true, value: value };
}

// Convenience wrapper around the above getter that will wait until a value is
// available on the server.
async function nextValueFromServer(key) {
  // Resolve the key if it is a Promise.
  key = await key;

  while (true) {
    // Fetches the test result from the server.
    const { status, value } = await readValueFromServer(key);
    if (!status) {
      // The test result has not been stored yet. Retry after a while.
      await new Promise(resolve => setTimeout(resolve, 20));
      continue;
    }

    return value;
  }
}

// Checks the beacon data server to see if it has received a beacon with a given
// event type and body.
async function readBeaconDataFromServer(event_type, expected_body) {
  let serverURL = `${BEACON_URL}`;
  const response = await fetch(serverURL + "?" + new URLSearchParams({
    type: event_type,
    expected_body: expected_body,
  }));
  if (!response.ok)
    throw new Error('An error happened in the server ' + response.status);
  const value = await response.text();

  // The value is not stored in the server.
  if (value === "<Not set>")
    return { status: false };

  return { status: true, value: value };
}

// Convenience wrapper around the above getter that will wait until a value is
// available on the server. The server uses a hash of the concatenated event
// type and beacon data as the key when storing the beacon in the database. To
// retrieve it, we need to supply the endpoint with both pieces of information.
async function nextBeacon(event_type, expected_body) {
  while (true) {
    // Fetches the test result from the server.
    const {status, value} =
        await readBeaconDataFromServer(event_type, expected_body);
    if (!status) {
      // The test result has not been stored yet. Retry after a while.
      await new Promise(resolve => setTimeout(resolve, 20));
      continue;
    }

    return value;
  }
}

// Writes `value` for `key` in the key-value store on the server.
async function writeValueToServer(key, value, origin = '') {
  // Resolve the key if it is a Promise.
  key = await key;

  const serverURL = `${origin}${STORE_URL}?key=${key}&value=${value}`;
  await fetch(serverURL, {"mode": "no-cors"});
}

// Fenced frames are always put in the public IP address space which is the
// least privileged. In case a navigation to a local data: URL or blob: URL
// resource is allowed, they would only be able to fetch things that are *also*
// in the public IP address space. So for the document described by these local
// URLs, we'll set them up to only communicate back to the outer page via
// resources obtained in the public address space.
function createLocalSource(key, url) {
  return `
    <head>
      <script src="${url}"><\/script>
    </head>
    <body>
      <script>
        writeValueToServer("${key}", "LOADED", /*origin=*/"${url.origin}");
      <\/script>
    </body>
  `;
}

function setupCSP(csp, second_csp=null) {
  let headers = [];

  headers.push(["Content-Security-Policy", "fenced-frame-src " + csp]);
  if (second_csp != null) {
    headers.push(["Content-Security-Policy", "frame-src " + second_csp]);
  }

  const iframe = attachIFrameContext({headers: headers});

  return iframe;
}

// Clicking in WPT tends to be flaky (https://crbug.com/1066891), so you may
// need to click multiple times to have an effect. This function clicks at
// coordinates `{x, y}` relative to `click_origin`, by default 3 times. Should
// not be used for tests where multiple clicks have distinct impact on the state
// of the page, but rather to bruteforce through flakes that rely on only one
// click.
async function multiClick(x, y, click_origin, times = 3) {
  for (let i = 0; i < times; i++) {
    let actions = new test_driver.Actions();
    await actions.pointerMove(x, y, {origin: click_origin})
        .pointerDown()
        .pointerUp()
        .send();
  }
}
