/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs";

import { ShoppingProduct } from "chrome://global/content/shopping/ShoppingProduct.mjs";

let lazy = {};

let gAllActors = new Set();

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "optedIn",
  "browser.shopping.experience2023.optedIn",
  null,
  function optedInStateChanged() {
    for (let actor of gAllActors) {
      actor.optedInStateChanged();
    }
  }
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "adsEnabled",
  "browser.shopping.experience2023.ads.enabled",
  true
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "adsEnabledByUser",
  "browser.shopping.experience2023.ads.userEnabled",
  true,
  function adsEnabledByUserChanged() {
    for (let actor of gAllActors) {
      actor.adsEnabledByUserChanged();
    }
  }
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "adsExposure",
  "browser.shopping.experience2023.ads.exposure",
  false
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "autoOpenEnabled",
  "browser.shopping.experience2023.autoOpen.enabled",
  true
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "autoOpenEnabledByUser",
  "browser.shopping.experience2023.autoOpen.userEnabled",
  true,
  function autoOpenEnabledByUserChanged() {
    for (let actor of gAllActors) {
      actor.autoOpenEnabledByUserChanged();
    }
  }
);

export class ShoppingSidebarChild extends RemotePageChild {
  constructor() {
    super();
  }

  actorCreated() {
    super.actorCreated();
    gAllActors.add(this);
  }

  didDestroy() {
    this._destroyed = true;
    super.didDestroy?.();
    gAllActors.delete(this);
    this.#product?.off("analysis-progress", this.#onAnalysisProgress);
    this.#product?.uninit();
  }

  #productURI = null;
  #product = null;

  receiveMessage(message) {
    if (this.browsingContext.usePrivateBrowsing) {
      throw new Error("We should never be invoked in PBM.");
    }
    switch (message.name) {
      case "ShoppingSidebar:UpdateProductURL":
        let { url, isReload } = message.data;
        let uri = url ? Services.io.newURI(url) : null;
        // If we're going from null to null, bail out:
        if (!this.#productURI && !uri) {
          return null;
        }

        // If we haven't reloaded, check if the URIs represent the same product
        // as sites might change the URI after they have loaded (Bug 1852099).
        if (!isReload && this.isSameProduct(uri, this.#productURI)) {
          return null;
        }

        this.#productURI = uri;
        this.updateContent({ haveUpdatedURI: true });
        break;
      case "ShoppingSidebar:ShowKeepClosedMessage":
        this.sendToContent("ShowKeepClosedMessage");
        break;
      case "ShoppingSidebar:HideKeepClosedMessage":
        this.sendToContent("HideKeepClosedMessage");
        break;
      case "ShoppingSidebar:IsKeepClosedMessageShowing":
        return !!this.document.querySelector("shopping-container")
          ?.wrappedJSObject.showingKeepClosedMessage;
    }
    return null;
  }

  isSameProduct(newURI, currentURI) {
    if (!newURI || !currentURI) {
      return false;
    }

    // Check if the URIs are equal:
    if (currentURI.equalsExceptRef(newURI)) {
      return true;
    }

    if (!this.#product) {
      return false;
    }

    // If the current ShoppingProduct has product info set,
    // check if the product ids are the same:
    let currentProduct = this.#product.product;
    if (currentProduct) {
      let newProduct = ShoppingProduct.fromURL(URL.fromURI(newURI));
      if (newProduct.id === currentProduct.id) {
        return true;
      }
    }

    return false;
  }

  handleEvent(event) {
    let aid, sponsored;
    switch (event.type) {
      case "ContentReady":
        this.updateContent();
        break;
      case "PolledRequestMade":
        this.updateContent({ isPolledRequest: true });
        break;
      case "ReportProductAvailable":
        this.reportProductAvailable();
        break;
      case "AdClicked":
        aid = event.detail.aid;
        sponsored = event.detail.sponsored;
        ShoppingProduct.sendAttributionEvent("click", aid);
        Glean.shopping.surfaceAdsClicked.record({ sponsored });
        break;
      case "AdImpression":
        aid = event.detail.aid;
        sponsored = event.detail.sponsored;
        ShoppingProduct.sendAttributionEvent("impression", aid);
        Glean.shopping.surfaceAdsImpression.record({ sponsored });
        break;
      case "DisableShopping":
        this.sendAsyncMessage("DisableShopping");
        break;
    }
  }

  // Exposed for testing. Assumes uri is a nsURI.
  set productURI(uri) {
    if (!(uri instanceof Ci.nsIURI)) {
      throw new Error("productURI setter expects an nsIURI");
    }
    this.#productURI = uri;
  }

  // Exposed for testing. Assumes product is a ShoppingProduct.
  set product(product) {
    if (!(product instanceof ShoppingProduct)) {
      throw new Error("product setter expects an instance of ShoppingProduct");
    }
    this.#product = product;
  }

  get canFetchAndShowData() {
    return lazy.optedIn === 1;
  }

  get adsEnabled() {
    return lazy.adsEnabled;
  }

  get adsEnabledByUser() {
    return lazy.adsEnabledByUser;
  }

  get canFetchAndShowAd() {
    return this.adsEnabled && this.adsEnabledByUser;
  }

  get autoOpenEnabled() {
    return lazy.autoOpenEnabled;
  }

  get autoOpenEnabledByUser() {
    return lazy.autoOpenEnabledByUser;
  }

  optedInStateChanged() {
    // Force re-fetching things if needed by clearing the last product URI:
    this.#productURI = null;
    // Then let content know.
    this.updateContent({ focusCloseButton: true });
  }

  adsEnabledByUserChanged() {
    this.sendToContent("adsEnabledByUserChanged", {
      adsEnabledByUser: this.adsEnabledByUser,
    });

    this.requestRecommendations(this.#productURI);
  }

  autoOpenEnabledByUserChanged() {
    this.sendToContent("autoOpenEnabledByUserChanged", {
      autoOpenEnabledByUser: this.autoOpenEnabledByUser,
    });
  }

  getProductURI() {
    return this.#productURI;
  }

  /**
   * This callback is invoked whenever something changes that requires
   * re-rendering content. The expected cases for this are:
   * - page navigations (both to new products and away from a product once
   *   the sidebar has been created)
   * - opt in state changes.
   *
   * @param {object?} options
   *        Optional parameter object.
   * @param {bool} options.haveUpdatedURI = false
   *        Whether we've got an up-to-date URI already. If true, we avoid
   *        fetching the URI from the parent, and assume `this.#productURI`
   *        is current. Defaults to false.
   * @param {bool} options.isPolledRequest = false
   */
  async updateContent({
    haveUpdatedURI = false,
    isPolledRequest = false,
    focusCloseButton = false,
  } = {}) {
    // updateContent is an async function, and when we're off making requests or doing
    // other things asynchronously, the actor can be destroyed, the user
    // might navigate to a new page, the user might disable the feature ... -
    // all kinds of things can change. So we need to repeatedly check
    // whether we can keep going with our async processes. This helper takes
    // care of these checks.
    let canContinue = (currentURI, checkURI = true) => {
      if (this._destroyed || !this.canFetchAndShowData) {
        return false;
      }
      if (!checkURI) {
        return true;
      }
      return currentURI && currentURI == this.#productURI;
    };
    this.#product?.off("analysis-progress", this.#onAnalysisProgress);
    this.#product?.uninit();
    // We are called either because the URL has changed or because the opt-in
    // state has changed. In both cases, we want to clear out content
    // immediately, without waiting for potentially async operations like
    // obtaining product information.
    // Do not clear data however if an analysis was requested via a call-to-action.
    if (!isPolledRequest) {
      this.sendToContent("Update", {
        adsEnabled: this.adsEnabled,
        adsEnabledByUser: this.adsEnabledByUser,
        autoOpenEnabled: this.autoOpenEnabled,
        autoOpenEnabledByUser: this.autoOpenEnabledByUser,
        showOnboarding: !this.canFetchAndShowData,
        data: null,
        recommendationData: null,
        focusCloseButton,
      });
    }
    if (this.canFetchAndShowData) {
      if (!this.#productURI) {
        // If we already have a URI and it's just null, bail immediately.
        if (haveUpdatedURI) {
          return;
        }
        let url = await this.sendQuery("GetProductURL");

        // Bail out if we opted out in the meantime, or don't have a URI.
        if (!canContinue(null, false)) {
          return;
        }

        this.#productURI = Services.io.newURI(url);
      }

      let uri = this.#productURI;
      this.#product = new ShoppingProduct(uri);
      this.#product.on(
        "analysis-progress",
        this.#onAnalysisProgress.bind(this)
      );

      let data;
      let isAnalysisInProgress;

      try {
        let analysisStatusResponse;
        if (isPolledRequest) {
          // Request a new analysis.
          analysisStatusResponse = await this.#product.requestCreateAnalysis();
        } else {
          // Check if there is an analysis in progress.
          analysisStatusResponse =
            await this.#product.requestAnalysisCreationStatus();
        }
        let analysisStatus = analysisStatusResponse?.status;

        isAnalysisInProgress =
          analysisStatus &&
          (analysisStatus == "pending" || analysisStatus == "in_progress");
        if (isAnalysisInProgress) {
          // Only clear the existing data if the update wasn't
          // triggered by a Polled Request event as re-analysis should
          // keep any stale data visible while processing.
          if (!isPolledRequest) {
            this.sendToContent("Update", {
              isAnalysisInProgress,
            });
          }
          analysisStatusResponse = await this.#product.pollForAnalysisCompleted(
            {
              pollInitialWait: analysisStatus == "in_progress" ? 0 : undefined,
            }
          );
          analysisStatus = analysisStatusResponse?.status;
          isAnalysisInProgress = false;
        }

        // Use the analysis status instead of re-requesting unnecessarily,
        // or throw if the status from the last analysis was an error.
        switch (analysisStatus) {
          case "not_analyzable":
          case "page_not_supported":
            data = { page_not_supported: true };
            break;
          case "not_enough_reviews":
            data = { not_enough_reviews: true };
            break;
          case "unprocessable":
          case "stale":
            throw new Error(analysisStatus, { cause: analysisStatus });
          default:
          // Status is "completed" or "not_found" (no analysis status),
          // so we should request the analysis data.
        }

        if (!data) {
          data = await this.#product.requestAnalysis();
          if (!data) {
            throw new Error("request failed");
          }
        }
      } catch (err) {
        console.error("Failed to fetch product analysis data", err);
        data = { error: err };
      }
      // Check if we got nuked from orbit, or the product URI or opt in changed while we waited.
      if (!canContinue(uri)) {
        return;
      }

      this.sendToContent("Update", {
        adsEnabled: this.adsEnabled,
        adsEnabledByUser: this.adsEnabledByUser,
        autoOpenEnabled: this.autoOpenEnabled,
        autoOpenEnabledByUser: this.autoOpenEnabledByUser,
        showOnboarding: false,
        data,
        productUrl: this.#productURI.spec,
        isAnalysisInProgress,
      });

      if (!data || data.error) {
        return;
      }

      if (!isPolledRequest && !data.grade) {
        Glean.shopping.surfaceNoReviewReliabilityAvailable.record();
      }

      this.requestRecommendations(uri);
    } else {
      // Don't bother continuing if the user has opted out.
      if (lazy.optedIn == 2) {
        return;
      }
      let url = await this.sendQuery("GetProductURL");

      // Similar to canContinue() above, check to see if things
      // have changed while we were waiting. Bail out if the user
      // opted in, or if the actor doesn't exist.
      if (this._destroyed || this.canFetchAndShowData) {
        return;
      }

      this.#productURI = Services.io.newURI(url);
      // Send the productURI to content for Onboarding's dynamic text
      this.sendToContent("Update", {
        showOnboarding: true,
        data: null,
        productUrl: this.#productURI.spec,
      });
    }
  }

  /**
   * Utility function to determine if we should request ads.
   */
  canFetchAds(uri) {
    return (
      uri.equalsExceptRef(this.#productURI) &&
      this.canFetchAndShowData &&
      (lazy.adsExposure || this.canFetchAndShowAd)
    );
  }

  /**
   * Utility function to determine if we should display ads. This is different
   * from fetching ads, because of ads exposure telemetry (bug 1858470).
   */
  canShowAds(uri) {
    return (
      uri.equalsExceptRef(this.#productURI) &&
      this.canFetchAndShowData &&
      this.canFetchAndShowAd
    );
  }

  /**
   * Request recommended products for a given uri and send the recommendations
   * to the content if recommendations are enabled.
   *
   * @param {nsIURI} uri The uri of the current product page
   */
  async requestRecommendations(uri) {
    if (!this.canFetchAds(uri)) {
      return;
    }

    let recommendationData = await this.#product.requestRecommendations();

    // Note: this needs to be separate from the inverse conditional check below
    // because here we want to know if an ad exists for the product, regardless
    // of whether ads are enabled, while for the surfaceNoAdsAvailable Glean
    // probe, we want to know if ads would have been shown, but one wasn't
    // available.
    if (recommendationData.length) {
      Glean.shopping.adsExposure.record();
    }

    // Check if the product URI or opt in changed while we waited.
    if (!this.canShowAds(uri)) {
      return;
    }

    if (!recommendationData.length) {
      // We tried to fetch an ad, but didn't get one.
      Glean.shopping.surfaceNoAdsAvailable.record();
    } else {
      let sponsored = recommendationData[0].sponsored;

      ShoppingProduct.sendAttributionEvent(
        "placement",
        recommendationData[0].aid
      );

      Glean.shopping.surfaceAdsPlacement.record({
        sponsored,
      });
    }

    this.sendToContent("UpdateRecommendations", {
      recommendationData,
    });
  }

  sendToContent(eventName, detail) {
    if (this._destroyed) {
      return;
    }
    let win = this.contentWindow;
    let evt = new win.CustomEvent(eventName, {
      bubbles: true,
      detail: Cu.cloneInto(detail, win),
    });
    win.document.dispatchEvent(evt);
  }

  async reportProductAvailable() {
    await this.#product.sendReport();
  }

  #onAnalysisProgress(eventName, progress) {
    this.sendToContent("UpdateAnalysisProgress", {
      progress,
    });
  }
}
