/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

Services.scriptloader.loadSubScript(
  "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js",
  this
);

// beforeReload references an object representing the initialized state of the
// storage actor.
const beforeReload = {
  cookies: {
    "http://test1.example.org": ["c1", "cs2", "c3", "uc1"],
    "http://sectest1.example.org": ["uc1", "cs2"],
  },
  "indexed-db": {
    "http://test1.example.org": [
      JSON.stringify(["idb1", "obj1"]),
      JSON.stringify(["idb1", "obj2"]),
      JSON.stringify(["idb2", "obj3"]),
    ],
    "http://sectest1.example.org": [],
  },
  "local-storage": {
    "http://test1.example.org": ["ls1", "ls2"],
    "http://sectest1.example.org": ["iframe-u-ls1"],
  },
  "session-storage": {
    "http://test1.example.org": ["ss1"],
    "http://sectest1.example.org": ["iframe-u-ss1", "iframe-u-ss2"],
  },
};

// afterIframeAdded references the items added when an iframe containing storage
// items is added to the page.
const afterIframeAdded = {
  cookies: {
    "https://sectest1.example.org": [
      getCookieId("cs2", ".example.org", "/"),
      getCookieId(
        "sc1",
        "sectest1.example.org",
        "/browser/devtools/server/tests/browser"
      ),
    ],
    "http://sectest1.example.org": [
      getCookieId(
        "sc1",
        "sectest1.example.org",
        "/browser/devtools/server/tests/browser"
      ),
    ],
  },
  "indexed-db": {
    // empty because indexed db creation happens after the page load, so at
    // the time of window-ready, there was no indexed db present.
    "https://sectest1.example.org": [],
  },
  "local-storage": {
    "https://sectest1.example.org": ["iframe-s-ls1"],
  },
  "session-storage": {
    "https://sectest1.example.org": ["iframe-s-ss1"],
  },
};

// afterIframeRemoved references the items deleted when an iframe containing
// storage items is removed from the page.
const afterIframeRemoved = {
  cookies: {
    "http://sectest1.example.org": [],
  },
  "indexed-db": {
    "http://sectest1.example.org": [],
  },
  "local-storage": {
    "http://sectest1.example.org": [],
  },
  "session-storage": {
    "http://sectest1.example.org": [],
  },
};

add_task(async function () {
  const { commands } = await openTabAndSetupStorage(
    MAIN_DOMAIN + "storage-dynamic-windows.html"
  );

  const { resourceCommand } = commands;
  const { TYPES } = resourceCommand;
  const allResources = {};
  const onAvailable = resources => {
    for (const resource of resources) {
      is(
        resource.targetFront.targetType,
        commands.targetCommand.TYPES.FRAME,
        "Each storage resource has a valid 'targetFront' attribute"
      );
      // Because we have iframes, we have distinct targets, each spawning their own storage resource
      if (allResources[resource.resourceType]) {
        allResources[resource.resourceType].push(resource);
      } else {
        allResources[resource.resourceType] = [resource];
      }
    }
  };
  const parentProcessStorages = [TYPES.COOKIE, TYPES.INDEXED_DB];
  const contentProcessStorages = [TYPES.LOCAL_STORAGE, TYPES.SESSION_STORAGE];
  const allStorages = [...parentProcessStorages, ...contentProcessStorages];
  await resourceCommand.watchResources(allStorages, { onAvailable });
  is(
    Object.keys(allStorages).length,
    allStorages.length,
    "Got all the storage resources"
  );

  // Do a copy of all the initial storages as test function may spawn new resources for the same
  // type and override the initial ones.
  // We do not call unwatchResources as it would clear its cache and next call
  // to watchResources with ignoreExistingResources would break and reprocess all resources again.
  const initialResources = Object.assign({}, allResources);

  testWindowsBeforeReload(initialResources);

  await testAddIframe(commands, initialResources, {
    contentProcessStorages,
    parentProcessStorages,
  });

  await testRemoveIframe(commands, initialResources, {
    contentProcessStorages,
    parentProcessStorages,
  });

  await clearStorage();

  // Forcing GC/CC to get rid of docshells and windows created by this test.
  forceCollections();
  await commands.destroy();
  forceCollections();
});

function testWindowsBeforeReload(resources) {
  for (const storageType in beforeReload) {
    ok(resources[storageType], `${storageType} storage actor is present`);

    const hosts = {};
    for (const resource of resources[storageType]) {
      for (const [hostType, hostValues] of Object.entries(resource.hosts)) {
        if (!hosts[hostType]) {
          hosts[hostType] = [];
        }

        hosts[hostType].push(hostValues);
      }
    }

    // If this test is run with chrome debugging enabled we get an extra
    // key for "chrome". We don't want the test to fail in this case, so
    // ignore it.
    if (storageType == "indexedDB") {
      delete hosts.chrome;
    }

    is(
      Object.keys(hosts).length,
      Object.keys(beforeReload[storageType]).length,
      `Number of hosts for ${storageType} match`
    );
    for (const host in beforeReload[storageType]) {
      ok(hosts[host], `Host ${host} is present`);
    }
  }
}

/**
 * Wait for new storage resources to be created of the given types.
 */
async function waitForNewResourcesAndUpdates(commands, resourceTypes) {
  // When fission is off, we don't expect any new resource
  if (resourceTypes.length === 0) {
    return { newResources: [], updates: [] };
  }
  const { resourceCommand } = commands;
  let resolve;
  const promise = new Promise(r => (resolve = r));
  const allResources = {};
  const allUpdates = {};
  const onAvailable = resources => {
    for (const resource of resources) {
      if (resource.resourceType in allResources) {
        ok(false, `Got multiple ${resource.resourceTypes} resources`);
      }
      allResources[resource.resourceType] = resource;
      ok(true, `Got resource for ${resource.resourceType}`);

      // Stop watching for resources when we got them all
      if (Object.keys(allResources).length == resourceTypes.length) {
        resourceCommand.unwatchResources(resourceTypes, {
          onAvailable,
        });
      }

      // But also listen for updates on each new resource
      resource.once("single-store-update").then(update => {
        ok(true, `Got updates for ${resource.resourceType}`);
        allUpdates[resource.resourceType] = update;

        // Resolve only once we got all the updates, for all the resources
        if (Object.keys(allUpdates).length == resourceTypes.length) {
          resolve({ newResources: allResources, updates: allUpdates });
        }
      });
    }
  };
  await resourceCommand.watchResources(resourceTypes, {
    onAvailable,
    ignoreExistingResources: true,
  });
  return promise;
}

/**
 * Wait for single-store-update events on all the given storage resources.
 */
function waitForResourceUpdates(resources, resourceTypes) {
  const allUpdates = {};
  const promises = [];
  for (const type of resourceTypes) {
    // Resolves once any of the many resources for the given storage type updates
    const promise = Promise.any(
      resources[type].map(resource => resource.once("single-store-update"))
    );
    promise.then(update => {
      ok(true, `Got updates for ${type}`);
      allUpdates[type] = update;
    });
    promises.push(promise);
  }
  return Promise.all(promises).then(() => allUpdates);
}

async function testAddIframe(
  commands,
  resources,
  { contentProcessStorages, parentProcessStorages }
) {
  info("Testing if new iframe addition works properly");

  // Expect:
  // - new resources alongside single-store-update events for content process storages
  // - only single-store-update events for previous resources for parent process storages
  const onResources = waitForNewResourcesAndUpdates(
    commands,
    contentProcessStorages
  );
  const onUpdates = waitForResourceUpdates(resources, parentProcessStorages);

  await SpecialPowers.spawn(
    gBrowser.selectedBrowser,
    [ALT_DOMAIN_SECURED],
    secured => {
      const doc = content.document;

      const iframe = doc.createElement("iframe");
      iframe.src = secured + "storage-secured-iframe.html";

      doc.querySelector("body").appendChild(iframe);
    }
  );

  info("Wait for all resources");
  const { newResources, updates } = await onResources;
  info("Wait for all updates");
  const previousResourceUpdates = await onUpdates;

  for (const resourceType of contentProcessStorages) {
    const resource = newResources[resourceType];
    const expected = afterIframeAdded[resourceType];
    // The resource only comes with hosts, without any values.
    // Each host will be an empty array.
    Assert.deepEqual(
      Object.keys(resource.hosts),
      Object.keys(expected),
      `List of hosts for resource ${resourceType} is correct`
    );
    for (const host in resource.hosts) {
      is(
        resource.hosts[host].length,
        0,
        "For new resources, each host has no value and is an empty array"
      );
    }
    const update = updates[resourceType];
    const storageKey = resourceTypeToStorageKey(resourceType);
    Assert.deepEqual(
      update.added[storageKey],
      expected,
      "We get an update after the resource, with the host values"
    );
  }

  for (const resourceType of parentProcessStorages) {
    const expected = afterIframeAdded[resourceType];
    const update = previousResourceUpdates[resourceType];
    const storageKey = resourceTypeToStorageKey(resourceType);
    Assert.deepEqual(
      update.added[storageKey],
      expected,
      `We get an update after the resource ${resourceType}, with the host values`
    );
  }

  return newResources;
}

async function testRemoveIframe(
  commands,
  resources,
  { contentProcessStorages, parentProcessStorages }
) {
  info("Testing if iframe removal works properly");

  // We only get updates for parent process storages, content process storage
  // resources are wiped via their related target destruction.
  const onUpdates = waitForResourceUpdates(resources, parentProcessStorages);

  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
    for (const iframe of content.document.querySelectorAll("iframe")) {
      if (iframe.src.startsWith("http:")) {
        iframe.remove();
        break;
      }
    }
  });

  info("Wait for all updates");
  const previousResourceUpdates = await onUpdates;

  for (const resourceType of parentProcessStorages) {
    const expected = afterIframeRemoved[resourceType];
    const update = previousResourceUpdates[resourceType];
    const storageKey = resourceTypeToStorageKey(resourceType);
    Assert.deepEqual(
      update.deleted[storageKey],
      expected,
      `We get an update after the resource ${resourceType}, with the host values`
    );
  }

  // The iframe target is destroyed, which ends up destroying related resources
  const destroyedResourceTypes = [];
  for (const storageType in resources) {
    for (const resource of resources[storageType]) {
      if (resource.isDestroyed()) {
        destroyedResourceTypes.push(resource.resourceType);
      }
    }
  }
  Assert.deepEqual(
    destroyedResourceTypes.sort(),
    contentProcessStorages.sort(),
    "Content process storage resources have been destroyed [local and session storages]"
  );
}

/**
 * single-store-update emits objects using attributes with old "storage key" namings,
 * which is different from resource type namings.
 */
function resourceTypeToStorageKey(resourceType) {
  if (resourceType == "local-storage") {
    return "localStorage";
  }
  if (resourceType == "session-storage") {
    return "sessionStorage";
  }
  if (resourceType == "indexed-db") {
    return "indexedDB";
  }
  return resourceType;
}
