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

"use strict";

// Test the ResourceWatcher API around STYLESHEET.

const {
  ResourceWatcher,
} = require("devtools/shared/resources/resource-watcher");

const STYLE_TEST_URL = URL_ROOT_SSL + "style_document.html";

const EXISTING_RESOURCES = [
  {
    styleText: "body { color: lime; }",
    href: null,
    nodeHref:
      "https://example.com/browser/devtools/shared/resources/tests/style_document.html",
    isNew: false,
    disabled: false,
    ruleCount: 1,
    mediaRules: [],
  },
  {
    styleText: "body { margin: 1px; }",
    href:
      "https://example.com/browser/devtools/shared/resources/tests/style_document.css",
    nodeHref:
      "https://example.com/browser/devtools/shared/resources/tests/style_document.html",
    isNew: false,
    disabled: false,
    ruleCount: 1,
    mediaRules: [],
  },
  {
    styleText: "body { background-color: pink; }",
    href: null,
    nodeHref:
      "https://example.org/browser/devtools/shared/resources/tests/style_iframe.html",
    isNew: false,
    disabled: false,
    ruleCount: 1,
    mediaRules: [],
  },
  {
    styleText: "body { padding: 1px; }",
    href:
      "https://example.org/browser/devtools/shared/resources/tests/style_iframe.css",
    nodeHref:
      "https://example.org/browser/devtools/shared/resources/tests/style_iframe.html",
    isNew: false,
    disabled: false,
    ruleCount: 1,
    mediaRules: [],
  },
];

const ADDITIONAL_RESOURCE = {
  styleText:
    "@media all { body { color: red; } } @media print { body { color: cyan; } } body { font-size: 10px; }",
  href: null,
  nodeHref:
    "https://example.com/browser/devtools/shared/resources/tests/style_document.html",
  isNew: false,
  disabled: false,
  ruleCount: 3,
  mediaRules: [
    {
      conditionText: "all",
      mediaText: "all",
      matches: true,
      line: 1,
      column: 1,
    },
    {
      conditionText: "print",
      mediaText: "print",
      matches: false,
      line: 1,
      column: 37,
    },
  ],
};

const ADDITIONAL_FROM_ACTOR_RESOURCE = {
  styleText: "body { font-size: 10px; }",
  href: null,
  nodeHref:
    "https://example.com/browser/devtools/shared/resources/tests/style_document.html",
  isNew: true,
  disabled: false,
  ruleCount: 1,
  mediaRules: [],
};

add_task(async function() {
  await pushPref("devtools.testing.enableServerWatcherSupport", false);
  await testResourceAvailableFeature();
  await testResourceUpdateFeature();
  await testNestedResourceUpdateFeature();

  await pushPref("devtools.testing.enableServerWatcherSupport", true);
  await testResourceAvailableFeature();
  await testResourceUpdateFeature();
  await testNestedResourceUpdateFeature();
});

async function testResourceAvailableFeature() {
  info("Check resource available feature of the ResourceWatcher");

  const tab = await addTab(STYLE_TEST_URL);

  const { client, resourceWatcher, targetList } = await initResourceWatcher(
    tab
  );

  info("Check whether ResourceWatcher gets existing stylesheet");
  const availableResources = [];
  await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], {
    onAvailable: resources => availableResources.push(...resources),
  });

  is(
    availableResources.length,
    EXISTING_RESOURCES.length,
    "Length of existing resources is correct"
  );
  for (let i = 0; i < availableResources.length; i++) {
    const availableResource = availableResources[i];
    // We can not expect the resources to always be forwarded in the same order.
    // See intermittent Bug 1655016.
    const expectedResource = findMatchingExpectedResource(availableResource);
    ok(expectedResource, "Found a matching expected resource for the resource");
    await assertResource(availableResource, expectedResource);
  }

  info("Check whether ResourceWatcher gets additonal stylesheet");
  await ContentTask.spawn(
    tab.linkedBrowser,
    ADDITIONAL_RESOURCE.styleText,
    text => {
      const document = content.document;
      const stylesheet = document.createElement("style");
      stylesheet.textContent = text;
      document.body.appendChild(stylesheet);
    }
  );
  await waitUntil(
    () => availableResources.length === EXISTING_RESOURCES.length + 1
  );
  await assertResource(
    availableResources[availableResources.length - 1],
    ADDITIONAL_RESOURCE
  );

  info(
    "Check whether ResourceWatcher gets additonal stylesheet which is added by DevTool"
  );
  const styleSheetsFront = await targetList.targetFront.getFront("stylesheets");
  await styleSheetsFront.addStyleSheet(
    ADDITIONAL_FROM_ACTOR_RESOURCE.styleText
  );
  await waitUntil(
    () => availableResources.length === EXISTING_RESOURCES.length + 2
  );
  await assertResource(
    availableResources[availableResources.length - 1],
    ADDITIONAL_FROM_ACTOR_RESOURCE
  );

  await targetList.destroy();
  await client.close();
}

async function testResourceUpdateFeature() {
  info("Check resource update feature of the ResourceWatcher");

  const tab = await addTab(STYLE_TEST_URL);

  const { client, resourceWatcher, targetList } = await initResourceWatcher(
    tab
  );

  info("Setup the watcher");
  const availableResources = [];
  const updates = [];
  await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], {
    onAvailable: resources => availableResources.push(...resources),
    onUpdated: newUpdates => updates.push(...newUpdates),
  });
  is(
    availableResources.length,
    EXISTING_RESOURCES.length,
    "Length of existing resources is correct"
  );

  info("Check toggleDisabled function");
  const resource = availableResources[0];
  const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
  await styleSheetsFront.toggleDisabled(resource.resourceId);
  await waitUntil(() => updates.length === 1);

  // Check the content of the update object.
  assertUpdate(updates[0].update, {
    resourceId: resource.resourceId,
    updateType: "property-change",
  });
  is(
    updates[0].update.resourceUpdates.disabled,
    true,
    "resourceUpdates is correct"
  );

  // Check whether the cached resource is updated correctly.
  is(
    updates[0].resource.disabled,
    true,
    "cached resource is updated correctly"
  );

  // Check whether the actual stylesheet is updated correctly.
  const styleSheetDisabled = await ContentTask.spawn(
    tab.linkedBrowser,
    null,
    () => {
      const document = content.document;
      const stylesheet = document.styleSheets[0];
      return stylesheet.disabled;
    }
  );
  is(styleSheetDisabled, true, "actual stylesheet is is updated correctly");

  info("Check update function");
  const expectedMediaRules = [
    {
      conditionText: "screen",
      mediaText: "screen",
      matches: true,
    },
    {
      conditionText: "print",
      mediaText: "print",
      matches: false,
    },
  ];

  const updateCause = "updated-by-test";
  await styleSheetsFront.update(
    resource.resourceId,
    "@media screen { color: red; } @media print { color: green; } body { color: cyan; }",
    false,
    updateCause
  );
  await waitUntil(() => updates.length === 4);

  assertUpdate(updates[1].update, {
    resourceId: resource.resourceId,
    updateType: "property-change",
  });
  is(
    updates[1].update.resourceUpdates.ruleCount,
    3,
    "resourceUpdates is correct"
  );
  is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly");

  assertUpdate(updates[2].update, {
    resourceId: resource.resourceId,
    updateType: "style-applied",
    event: {
      cause: updateCause,
    },
  });
  is(
    updates[2].update.resourceUpdates,
    undefined,
    "resourceUpdates is correct"
  );

  assertUpdate(updates[3].update, {
    resourceId: resource.resourceId,
    updateType: "media-rules-changed",
  });
  assertMediaRules(
    updates[3].update.resourceUpdates.mediaRules,
    expectedMediaRules
  );

  // Check the actual page.
  const styleSheetResult = await getStyleSheetResult(tab);

  is(
    styleSheetResult.ruleCount,
    3,
    "ruleCount of actual stylesheet is updated correctly"
  );
  assertMediaRules(styleSheetResult.mediaRules, expectedMediaRules);

  await targetList.destroy();
  await client.close();
}

async function testNestedResourceUpdateFeature() {
  info("Check nested resource update feature of the ResourceWatcher");

  const tab = await addTab(STYLE_TEST_URL);

  const {
    outerWidth: originalWindowWidth,
    outerHeight: originalWindowHeight,
  } = tab.ownerGlobal;

  registerCleanupFunction(() => {
    tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight);
  });

  const { client, resourceWatcher, targetList } = await initResourceWatcher(
    tab
  );

  info("Setup the watcher");
  const availableResources = [];
  const updates = [];
  await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], {
    onAvailable: resources => availableResources.push(...resources),
    onUpdated: newUpdates => updates.push(...newUpdates),
  });
  is(
    availableResources.length,
    EXISTING_RESOURCES.length,
    "Length of existing resources is correct"
  );

  info("Apply new media query");
  // In order to avoid applying the media query (min-height: 400px).
  tab.ownerGlobal.resizeTo(originalWindowWidth, 300);

  const resource = availableResources[0];
  const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
  await styleSheetsFront.update(
    resource.resourceId,
    "@media (min-height: 400px) { color: red; }",
    false
  );
  await waitUntil(() => updates.length === 3);
  is(resource.mediaRules[0].matches, false, "Media query is not matched yet");

  info("Change window size to fire matches-change event");
  tab.ownerGlobal.resizeTo(originalWindowWidth, 500);
  await waitUntil(() => updates.length === 4);

  // Check the update content.
  const isServerWatcher = Services.prefs.getBoolPref(
    "devtools.testing.enableServerWatcherSupport"
  );
  const targetUpdate = updates[3];
  assertUpdate(targetUpdate.update, {
    resourceId: resource.resourceId,
    updateType: "matches-change",
  });
  ok(resource === targetUpdate.resource, "Update object has the same resource");

  if (isServerWatcher) {
    is(
      JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path),
      JSON.stringify(["mediaRules", 0, "matches"]),
      "path of nestedResourceUpdates is correct"
    );
    is(
      targetUpdate.update.nestedResourceUpdates[0].value,
      true,
      "value of nestedResourceUpdates is correct"
    );
  } else {
    is(
      JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path),
      JSON.stringify(["mediaRules", 0]),
      "path of nestedResourceUpdates is correct"
    );
    is(
      targetUpdate.update.nestedResourceUpdates[0].value.matches,
      true,
      "value of nestedResourceUpdates is correct"
    );
  }

  // Check the resource.
  const expectedMediaRules = [
    {
      conditionText: "(min-height: 400px)",
      mediaText: "(min-height: 400px)",
      matches: true,
    },
  ];

  assertMediaRules(targetUpdate.resource.mediaRules, expectedMediaRules);

  // Check the actual page.
  const styleSheetResult = await getStyleSheetResult(tab);
  is(
    styleSheetResult.ruleCount,
    1,
    "ruleCount of actual stylesheet is updated correctly"
  );
  assertMediaRules(styleSheetResult.mediaRules, expectedMediaRules);

  tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight);

  await targetList.destroy();
  await client.close();
}

function findMatchingExpectedResource(resource) {
  return EXISTING_RESOURCES.find(
    expected =>
      resource.href === expected.href && resource.nodeHref === expected.nodeHref
  );
}

async function getStyleSheetResult(tab) {
  const result = await ContentTask.spawn(tab.linkedBrowser, null, () => {
    const document = content.document;
    const stylesheet = document.styleSheets[0];
    const ruleCount = stylesheet.cssRules.length;

    const mediaRules = [];
    for (const rule of stylesheet.cssRules) {
      if (!rule.media) {
        continue;
      }

      let matches = false;
      try {
        const mql = content.matchMedia(rule.media.mediaText);
        matches = mql.matches;
      } catch (e) {
        // Ignored
      }

      mediaRules.push({
        mediaText: rule.media.mediaText,
        conditionText: rule.conditionText,
        matches,
      });
    }

    return { ruleCount, mediaRules };
  });

  return result;
}

function assertMediaRules(mediaRules, expected) {
  is(mediaRules.length, expected.length, "Length of the mediaRules is correct");

  for (let i = 0; i < mediaRules.length; i++) {
    is(
      mediaRules[i].conditionText,
      expected[i].conditionText,
      "conditionText is correct"
    );
    is(mediaRules[i].mediaText, expected[i].mediaText, "mediaText is correct");
    is(mediaRules[i].matches, expected[i].matches, "matches is correct");

    if (expected[i].line !== undefined) {
      is(mediaRules[i].line, expected[i].line, "line is correct");
    }

    if (expected[i].column !== undefined) {
      is(mediaRules[i].column, expected[i].column, "column is correct");
    }
  }
}

async function assertResource(resource, expected) {
  is(
    resource.resourceType,
    ResourceWatcher.TYPES.STYLESHEET,
    "Resource type is correct"
  );
  const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
  const styleText = (
    await styleSheetsFront.getText(resource.resourceId)
  ).str.trim();
  is(styleText, expected.styleText, "Style text is correct");
  is(resource.href, expected.href, "href is correct");
  is(resource.nodeHref, expected.nodeHref, "nodeHref is correct");
  is(resource.isNew, expected.isNew, "isNew is correct");
  is(resource.disabled, expected.disabled, "disabled is correct");
  is(resource.ruleCount, expected.ruleCount, "ruleCount is correct");
  assertMediaRules(resource.mediaRules, expected.mediaRules);
}

function assertUpdate(update, expected) {
  is(
    update.resourceType,
    ResourceWatcher.TYPES.STYLESHEET,
    "Resource type is correct"
  );
  is(update.resourceId, expected.resourceId, "resourceId is correct");
  is(update.updateType, expected.updateType, "updateType is correct");
  if (expected.event?.cause) {
    is(update.event?.cause, expected.event.cause, "cause is correct");
  }
}
