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

"use strict";

ChromeUtils.defineESModuleGetters(this, {
  featureEngineIdToFluentId: "chrome://global/content/ml/Utils.sys.mjs",
});

const RED_ICON_DATA =
  "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEgw1XkM0ygAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAYSURBVCjPY/zPQA5gYhjVNqptVNsg1wYAItkBI/GNR3YAAAAASUVORK5CYII=";
const IMAGE_ARRAYBUFFER_RED = imageBufferFromDataURI(RED_ICON_DATA);
const FEATURE_ICON = "chrome://branding/content/icon64.png";

function imageBufferFromDataURI(encodedImageData) {
  let decodedImageData = atob(encodedImageData);
  return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer;
}

async function getTestExtension({ id, withIcon }) {
  const testExt = ExtensionTestUtils.loadExtension({
    useAddonManager: "permanent",
    manifest: {
      name: `name-${id}`,
      icons: withIcon ? { 16: "test-icon.png" } : undefined,
      browser_specific_settings: {
        gecko: { id },
      },
    },
    files: withIcon ? { "test-icon.png": IMAGE_ARRAYBUFFER_RED } : undefined,
  });
  await testExt.startup();
  return testExt;
}

let mockProvider;
let promptService;
add_setup(async function () {
  mockProvider = new MockProvider(["mlmodel"]);
  promptService = mockPromptService();
});

/**
 * Test that model hub provider is conditionally shown in the sidebar.
 */
add_task(async function testModelHubProvider() {
  let win = await loadInitialView("extension");
  let modelHubCategory = AboutAddonsTestUtils.getCategoryButton(win, "mlmodel");

  await TestUtils.waitForCondition(async () => {
    return modelHubCategory.hidden;
  }, "Wait for the mlmodel category button to be hidden");

  ok(modelHubCategory.hidden, "Model hub category is hidden");

  await closeView(win);
  mockProvider.createAddons([
    {
      id: "mockmodel1@tests.mozilla.org",
      name: "Model Mock 1",
      permissions: AddonManager.PERM_CAN_UNINSTALL,
      type: "mlmodel",
      usedByFirefoxFeatures: [],
      usedByAddonIds: [],
    },
    {
      id: "mockmodel2@tests.mozilla.org",
      name: "Model Mock 2",
      permissions: AddonManager.PERM_CAN_UNINSTALL,
      type: "mlmodel",
      usedByFirefoxFeatures: [],
      usedByAddonIds: [],
    },
  ]);
  win = await loadInitialView("extension");
  let doc = win.document;
  modelHubCategory = AboutAddonsTestUtils.getCategoryButton(win, "mlmodel");
  await TestUtils.waitForCondition(async () => {
    return !modelHubCategory.hidden;
  }, "Wait for the mlmodel category button to not be hidden");

  ok(!modelHubCategory.hidden, "Model hub category is shown");

  let mlmodelLoaded = waitForViewLoad(win);
  AboutAddonsTestUtils.clickCategoryButton(win, "mlmodel");
  await mlmodelLoaded;

  let enabledSection = getSection(doc, "mlmodel-enabled-section");
  is(
    enabledSection.children.length,
    2,
    "Got the expected number of mlmodel entries"
  );
  ok(
    !enabledSection.querySelector(".list-section-heading"),
    "No heading for mlmodel"
  );
  is(
    enabledSection.previousSibling.localName,
    "mlmodel-list-intro",
    "Model hub custom section is shown"
  );

  let promiseListUpdated = BrowserTestUtils.waitForEvent(
    enabledSection.closest("addon-list"),
    "remove"
  );
  info("Uninstall one of the mlmodel entries");
  const mlmodelmock2 = await AddonManager.getAddonByID(
    "mockmodel2@tests.mozilla.org"
  );

  await mlmodelmock2.uninstall();
  info("Wait for the list of mlmodel entries to be updated");
  await promiseListUpdated;

  enabledSection = getSection(doc, "mlmodel-enabled-section");
  is(
    enabledSection.children.length,
    1,
    "Got the expected number of mlmodel entries"
  );

  promiseListUpdated = BrowserTestUtils.waitForEvent(
    enabledSection.closest("addon-list"),
    "remove"
  );
  info("Uninstall the last one of the mlmodel entries");
  const mlmodelmock1 = await AddonManager.getAddonByID(
    "mockmodel1@tests.mozilla.org"
  );

  await mlmodelmock1.uninstall();
  info("Wait for the list of mlmodel entries to be updated");
  await promiseListUpdated;

  enabledSection = getSection(doc, "mlmodel-enabled-section");
  is(
    enabledSection.children.length,
    0,
    "Expect mlmodel add-ons list view to be empty"
  );

  let emptyMessageFindModeOnAMO = doc.querySelector("#empty-addons-message");
  is(
    emptyMessageFindModeOnAMO,
    null,
    "Expect no #empty-addons-message element in the empty mlmodel list view"
  );

  await closeView(win);
});

/**
 * Test model hub card in the list view.
 */
add_task(async function testModelHubCard() {
  const expectedTelemetryCount = 1;
  Services.fog.testResetFOG();
  const extWithIcon = await getTestExtension({
    id: "addon-with-icon@test-extension",
    withIcon: true,
  });
  const extWithoutIcon = await getTestExtension({
    id: "addon-without-icon@test-extension",
    withIcon: false,
  });
  const id1 = "mockmodel1-without-size@tests.mozilla.org";
  const id2 = "mockmodel2-with-size@tests.mozilla.org";

  mockProvider.createAddons([
    {
      id: id1,
      name: "Model Mock 1",
      permissions: AddonManager.PERM_CAN_UNINSTALL,
      type: "mlmodel",
      totalSize: undefined,
      // Testing a model using one of the expected Firefox features.
      usedByFirefoxFeatures: ["about-inference"],
      // Testing extension using the models (one with its own icon and
      // one without any icon).
      usedByAddonIds: [extWithIcon.id, extWithoutIcon.id],
    },
    {
      id: id2,
      name: "Model Mock 2",
      permissions: AddonManager.PERM_CAN_UNINSTALL,
      type: "mlmodel",
      totalSize: 5 * 1024 * 1024,
      // Testing that a Firefox feature that is mistakenly missing a
      // corresponding fluent id is omitted.
      usedByFirefoxFeatures: [
        "non-existing-feature",
        "smart-tab-embedding-engine",
      ],
      // Testing that a non existing extension is omitted.
      usedByAddonIds: ["non-existing@test-extension", extWithIcon.id],
    },
  ]);

  let win = await loadInitialView("mlmodel");

  info("Test list view telemetry");
  let listViewEvent = Glean.modelManagement.listView.testGetValue() || [];
  Assert.equal(
    listViewEvent.length,
    expectedTelemetryCount,
    "Got the expected listView telemetry"
  );

  // Card No Size
  let card1 = getAddonCard(win, id1);
  ok(card1, `Found addon card for model ${id1}`);
  verifyAddonCard(card1, {
    expectedTotalSize: "0 bytes",
    expectedUsedBy: [
      {
        iconURL: FEATURE_ICON,
        fluentId: featureEngineIdToFluentId("about-inference"),
      },
      {
        iconURL: /\/test-icon\.png$/,
        fluentId: "mlmodel-extension-label",
        fluentArgs: { extensionName: `name-${extWithIcon.id}` },
      },
      {
        iconURL: /\/extensionGeneric.svg$/,
        fluentId: "mlmodel-extension-label",
        fluentArgs: { extensionName: `name-${extWithoutIcon.id}` },
      },
    ],
  });

  // Card With Size
  let card2 = getAddonCard(win, id2);
  ok(card2, `Found addon card for model ${id2}`);
  verifyAddonCard(card2, {
    expectedTotalSize: "5.0 MB",
    expectedUsedBy: [
      {
        iconURL: FEATURE_ICON,
        fluentId: featureEngineIdToFluentId("smart-tab-embedding-engine"),
      },
      {
        iconURL: /\/test-icon\.png$/,
        fluentId: "mlmodel-extension-label",
        fluentArgs: { extensionName: `name-${extWithIcon.id}` },
      },
    ],
  });

  await closeView(win);
  await extWithIcon.unload();
  await extWithoutIcon.unload();

  function verifyAddonCard(card, { expectedTotalSize, expectedUsedBy }) {
    ok(!card.hasAttribute("expanded"), "The list card is not expanded");

    let mlmodelTotalSizeBubble = card.querySelector(
      ".mlmodel-total-size-bubble"
    );
    ok(mlmodelTotalSizeBubble, "Expect to see the mlmodel total size bubble");

    is(
      mlmodelTotalSizeBubble?.textContent.trim(),
      expectedTotalSize,
      "Got the expected total size text"
    );

    let mlmodelRemoveAddonButton = card.querySelector(
      ".mlmodel-remove-addon-button"
    );

    ok(
      !mlmodelRemoveAddonButton,
      "Expect to not see the mlmodel remove addon button"
    );

    ok(
      BrowserTestUtils.isVisible(card.optionsButton),
      "Expect the card options button to be visible in the list view"
    );

    const listAdditionEl = card.querySelector("mlmodel-card-list-additions");
    ok(listAdditionEl, "Found mlmodel-card-list-additions element");
    const usedByEls =
      listAdditionEl.shadowRoot.querySelectorAll(".mlmodel-used-by");
    for (const [idx, usedBy] of expectedUsedBy.entries()) {
      info(`Verifying usedBy entry: ${JSON.stringify(usedBy)}\n`);
      const img = usedByEls[idx].querySelector("img");
      ok(img, "Found img tag for the icon url");
      if (usedBy.iconURL instanceof RegExp) {
        ok(
          usedBy.iconURL.test(img.src),
          `Expected icon url ${img.src} to match ${usedBy.iconURL}`
        );
      } else {
        Assert.equal(img.src, usedBy.iconURL, "Got the expected icon url");
      }
      const label = usedByEls[idx].querySelector("label");
      const fluentAttrs = label.ownerDocument.l10n.getAttributes(label);
      Assert.equal(
        fluentAttrs.id,
        usedBy.fluentId,
        "Got the expected fluent id"
      );
      if (usedBy.fluentArgs) {
        Assert.deepEqual(
          fluentAttrs.args,
          usedBy.fluentArgs,
          "Got the expected fluent args"
        );
      }
    }
    Assert.equal(
      usedByEls.length,
      expectedUsedBy.length,
      "Got the expected number of model usedBy elements"
    );
  }
});

/**
 * Test model hub expanded details.
 */
add_task(async function testModelHubDetails() {
  const extWithIcon = await getTestExtension({
    id: "addon-with-icon@test-extension",
    withIcon: true,
  });
  const extWithoutIcon = await getTestExtension({
    id: "addon-without-icon@test-extension",
    withIcon: false,
  });
  const id1 = "mockmodel1-without-size@tests.mozilla.org";
  const id2 = "mockmodel2-with-size@tests.mozilla.org";

  const mockModel1 = {
    id: id1,
    model: "hostname/org/model-mock-1",
    permissions: AddonManager.PERM_CAN_UNINSTALL,
    type: "mlmodel",
    totalSize: undefined,
    lastUsed: new Date("2023-10-01T12:00:00Z"),
    updateDate: new Date("2023-10-01T12:00:00Z"),
    modelHomepageURL: "https://huggingface.co/org/model-mock-1",
    modelIconURL: "chrome://mozapps/skin/extensions/extensionGeneric.svg",
    // Testing a model using one of the expected Firefox features.
    usedByFirefoxFeatures: ["about-inference"],
    // Testing extension using the models (one with its own icon and
    // one without any icon).
    usedByAddonIds: [extWithIcon.id, extWithoutIcon.id],
    engineIds: [],
  };
  const mockModel2 = {
    id: id2,
    model: "hostname/org/model-mock-2",
    permissions: AddonManager.PERM_CAN_UNINSTALL,
    type: "mlmodel",
    totalSize: 5 * 1024 * 1024,
    lastUsed: new Date("2023-10-01T12:00:00Z"),
    updateDate: new Date("2023-10-01T12:00:00Z"),
    modelHomepageURL: "https://huggingface.co/org/model-mock-2",
    modelIconURL: "", // testing that empty icon sets to defult svg
    // Testing that a Firefox feature that is mistakenly missing a
    // corresponding fluent id is omitted.
    usedByFirefoxFeatures: [
      "non-existing-feature",
      "smart-tab-embedding-engine",
    ],
    // Testing that a non existing extension is omitted.
    usedByAddonIds: ["non-existing@test-extension", extWithIcon.id],
    engineIds: [],
  };

  mockProvider.createAddons([mockModel1, mockModel2]);

  await verifyAddonCardDetails({
    id: id1,
    expectedTotalSize: "0 bytes",
    expectedLastUsed: mockModel1.lastUsed,
    expectedModelHomepageURL: mockModel1.modelHomepageURL,
    expectedModelIconURL:
      "chrome://mozapps/skin/extensions/extensionGeneric.svg",
    expectedUsedBy: [
      {
        iconURL: FEATURE_ICON,
        fluentId: featureEngineIdToFluentId("about-inference"),
      },
      {
        iconURL: /\/test-icon\.png$/,
        fluentId: "mlmodel-extension-label",
        fluentArgs: { extensionName: `name-${extWithIcon.id}` },
      },
      {
        iconURL: /\/extensionGeneric.svg$/,
        fluentId: "mlmodel-extension-label",
        fluentArgs: { extensionName: `name-${extWithoutIcon.id}` },
      },
    ],
    expectedGleanExtraSize: undefined,
  });
  await verifyAddonCardDetails({
    id: id2,
    expectedTotalSize: "5.0 MB",
    expectedLastUsed: mockModel2.lastUsed,
    expectedModelHomepageURL: mockModel2.modelHomepageURL,
    expectedModelIconURL:
      "chrome://mozapps/skin/extensions/extensionGeneric.svg",
    expectedUsedBy: [
      {
        iconURL: FEATURE_ICON,
        fluentId: featureEngineIdToFluentId("smart-tab-embedding-engine"),
      },
      {
        iconURL: /\/test-icon\.png$/,
        fluentId: "mlmodel-extension-label",
        fluentArgs: { extensionName: `name-${extWithIcon.id}` },
      },
    ],
    expectedGleanExtraSize: "5242880",
  });

  async function verifyAddonCardDetails({
    id,
    expectedTotalSize,
    expectedLastUsed,
    expectedModelHomepageURL,
    expectedModelIconURL,
    expectedUsedBy,
    expectedGleanExtraSize,
  }) {
    Services.fog.testResetFOG();
    let win = await loadInitialView("mlmodel");

    // Get the list view card DOM element for the given addon id.
    let card = getAddonCard(win, id);
    ok(card, `Found addon card for model ${id}`);
    ok(!card.hasAttribute("expanded"), "list view card is not expanded");

    // Load the detail view.
    let loaded = waitForViewLoad(win);
    card.querySelector('[action="expand"]').click();
    await loaded;

    info("Test detials view telemetry");
    let detailsViewEvent =
      Glean.modelManagement.detailsView.testGetValue() || [];
    Assert.equal(
      detailsViewEvent.length,
      1,
      "Got the expected detailsView telemetry"
    );

    info("Test list management button click telemetry");
    let listItemManageEvent =
      Glean.modelManagement.listItemManage.testGetValue() || [];
    Assert.equal(
      listItemManageEvent.length,
      1,
      "Got the expected listItemManage telemetry"
    );

    // Get the detail view card DOM element for the given addon id.
    card = getAddonCard(win, id);
    ok(card.hasAttribute("expanded"), "detail view card is expanded");

    let mlmodelTotalSizeBubble = card.querySelector(
      ".mlmodel-total-size-bubble"
    );
    ok(
      !mlmodelTotalSizeBubble,
      "Expect to not see the mlmodel total size bubble"
    );

    let mlmodelRemoveAddonButton = card.querySelector(
      ".mlmodel-remove-addon-button"
    );
    ok(
      mlmodelRemoveAddonButton,
      "Expect to see the mlmodel remove addon button"
    );

    // Set response to cancel & Fire off Delete click
    promptService._response = 1;
    EventUtils.sendMouseEvent({ type: "click" }, mlmodelRemoveAddonButton);
    await BrowserTestUtils.waitForEvent(card, "remove-cancelled");
    info("Test cancel remove prompt telemetry");
    let cancelEvent =
      Glean.modelManagement.removeConfirmation.testGetValue() || [];
    Assert.equal(
      "cancel",
      cancelEvent[0].extra.action,
      "Got the expected removeConfirmation telemetry"
    );

    // Set response to confirm & Fire off Delete click
    promptService._response = 0;
    EventUtils.sendMouseEvent({ type: "click" }, mlmodelRemoveAddonButton);
    await BrowserTestUtils.waitForEvent(card, "remove");
    info("Test confirm remove prompt telemetry");
    let confirmEvent =
      Glean.modelManagement.removeConfirmation.testGetValue() || [];
    Assert.equal(
      "remove",
      confirmEvent[1].extra.action,
      "Got the expected removeConfirmation telemetry"
    );

    info("Test how many removes iniated telemetry");
    let removeInitiatedEvents =
      Glean.modelManagement.removeInitiated.testGetValue() || [];
    Assert.equal(
      removeInitiatedEvents.length,
      2,
      "Got the expected removeInitiated telemetry"
    );

    removeInitiatedEvents.forEach(({ extra }, i) => {
      Assert.equal(
        extra.size,
        expectedGleanExtraSize,
        `Got the expected size for remove initiated event ${i}`
      );
    });

    ok(
      !card.querySelector(".addon-detail-mlmodel").hidden,
      "Expect to see the mlmodel details"
    );

    ok(
      BrowserTestUtils.isHidden(card.optionsButton),
      "Expect the card options button to be hidden in the detail view"
    );

    // Check the model total size
    let totalsizeEl = card.querySelector(".addon-detail-row-mlmodel-totalsize");
    ok(totalsizeEl, "Expect to see the total size");
    is(
      totalsizeEl?.querySelector("span")?.textContent.trim(),
      expectedTotalSize,
      "Got the expected total size text"
    );

    // Check the last used date
    let lastUsedEl = card.querySelector(".addon-detail-row-mlmodel-lastused");
    ok(lastUsedEl, "Expect to see the last used date");
    is(
      lastUsedEl?.querySelector("span")?.textContent.trim(),
      expectedLastUsed.toLocaleDateString(undefined, {
        year: "numeric",
        month: "long",
        day: "numeric",
      }),
      "Got the expected last used date text"
    );

    // Check the model card link
    let modelCardEl = card.querySelector(".addon-detail-row-mlmodel-modelcard");
    ok(modelCardEl, "Expect to see the model card link");
    let modelHomepageURL = modelCardEl.querySelector("a");

    EventUtils.sendMouseEvent({ type: "click" }, modelHomepageURL);
    info("Test model card link telemetry");
    let modelCardLinkEvent =
      Glean.modelManagement.modelCardLink.testGetValue() || [];
    Assert.equal(
      modelCardLinkEvent.length,
      1,
      "Got the expected extensionModelLink telemetry"
    );

    ok(modelHomepageURL, "Expect to see the model card link element");
    is(
      modelHomepageURL?.href,
      expectedModelHomepageURL,
      "Got the expected model card link"
    );

    // Check the model version
    let versionEl = card.querySelector(".addon-detail-row-version");
    ok(versionEl, "Expect to see the model version");

    // Check the model image
    let iconSrc = card.querySelector(".card-heading-icon").getAttribute("src");
    ok(iconSrc, "Expected to see model card image src");
    is(iconSrc, expectedModelIconURL, "Got expected model card icon value");

    const detailsEl = card.querySelector("addon-mlmodel-details");
    ok(detailsEl, "Found mlmodel-card-list-additions element");
    const usedByEls = detailsEl.querySelectorAll(".mlmodel-used-by");
    info(`found usedByEls: ${usedByEls.length}`);
    for (const [idx, usedBy] of expectedUsedBy.entries()) {
      info(`Verifying usedBy entry: ${JSON.stringify(usedBy)}\n`);
      const img = usedByEls[idx].querySelector("img");
      ok(img, "Found img tag for the icon url");
      if (usedBy.iconURL instanceof RegExp) {
        ok(
          usedBy.iconURL.test(img.src),
          `Expected icon url ${img.src} to match ${usedBy.iconURL}`
        );
      } else {
        Assert.equal(img.src, usedBy.iconURL, "Got the expected icon url");
      }
      const label = usedByEls[idx].querySelector("label");
      const fluentAttrs = label.ownerDocument.l10n.getAttributes(label);
      Assert.equal(
        fluentAttrs.id,
        usedBy.fluentId,
        "Got the expected fluent id"
      );
      if (usedBy.fluentArgs) {
        Assert.deepEqual(
          fluentAttrs.args,
          usedBy.fluentArgs,
          "Got the expected fluent args"
        );
      }
    }
    Assert.equal(
      usedByEls.length,
      expectedUsedBy.length,
      "Got the expected number of model usedBy elements"
    );

    await closeView(win);
  }

  await extWithIcon.unload();
  await extWithoutIcon.unload();
});
