/* 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 { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import { getActiveCardSize } from "../../lib/utils";
import { TOP_SITES_SOURCE } from "../TopSites/TopSitesConstants";
import React from "react";

const VISIBLE = "visible";
const VISIBILITY_CHANGE_EVENT = "visibilitychange";

// Per analytical requirement, we set the minimal intersection ratio to
// 0.5, and an impression is identified when the wrapped item has at least
// 50% visibility.
//
// This constant is exported for unit test
export const INTERSECTION_RATIO = 0.5;

/**
 * Impression wrapper for Discovery Stream related React components.
 *
 * It makes use of the Intersection Observer API to detect the visibility,
 * and relies on page visibility to ensure the impression is reported
 * only when the component is visible on the page.
 *
 * Note:
 *   * This wrapper used to be used either at the individual card level,
 *     or by the card container components.
 *     It is now only used for individual card level.
 *   * Each impression will be sent only once as soon as the desired
 *     visibility is detected
 *   * Batching is not yet implemented, hence it might send multiple
 *     impression pings separately
 */
export class ImpressionStats extends React.PureComponent {
  // This checks if the given cards are the same as those in the last impression ping.
  // If so, it should not send the same impression ping again.
  _needsImpressionStats(cards) {
    if (
      !this.impressionCardGuids ||
      this.impressionCardGuids.length !== cards.length
    ) {
      return true;
    }

    for (let i = 0; i < cards.length; i++) {
      if (cards[i].id !== this.impressionCardGuids[i]) {
        return true;
      }
    }

    return false;
  }

  _dispatchImpressionStats() {
    const { props } = this;
    const { isFakespot } = props;
    const cards = props.rows;

    if (this.props.flightId) {
      this.props.dispatch(
        ac.OnlyToMain({
          type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
          data: { flightId: this.props.flightId },
        })
      );

      // Record sponsored topsites impressions if the source is `TOP_SITES_SOURCE`.
      if (this.props.source === TOP_SITES_SOURCE) {
        for (const card of cards) {
          this.props.dispatch(
            ac.OnlyToMain({
              type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS,
              data: {
                type: "impression",
                tile_id: card.id,
                source: "newtab",
                advertiser: card.advertiser,
                // Keep the 0-based position, can be adjusted by the telemetry
                // sender if necessary.
                position: card.pos,
              },
            })
          );
        }
      }
    }

    if (this._needsImpressionStats(cards)) {
      if (isFakespot) {
        props.dispatch(
          ac.DiscoveryStreamImpressionStats({
            source: props.source.toUpperCase(),
            window_inner_width: window.innerWidth,
            window_inner_height: window.innerHeight,
            tiles: cards.map(link => ({
              id: link.id,
              type: "fakespot",
              category: link.category,
            })),
          })
        );
      } else {
        props.dispatch(
          ac.DiscoveryStreamImpressionStats({
            source: props.source.toUpperCase(),
            window_inner_width: window.innerWidth,
            window_inner_height: window.innerHeight,
            tiles: cards.map(link => ({
              id: link.id,
              pos: link.pos,
              type: props.flightId ? "spoc" : "organic",
              ...(link.shim ? { shim: link.shim } : {}),
              recommendation_id: link.recommendation_id,
              fetchTimestamp: link.fetchTimestamp,
              corpus_item_id: link.corpus_item_id,
              scheduled_corpus_item_id: link.scheduled_corpus_item_id,
              recommended_at: link.recommended_at,
              received_rank: link.received_rank,
              topic: link.topic,
              features: link.features,
              is_list_card: link.is_list_card,
              ...(link.format
                ? { format: link.format }
                : {
                    format: getActiveCardSize(
                      window.innerWidth,
                      link.class_names,
                      link.section,
                      link.flightId
                    ),
                  }),
              ...(link.section
                ? {
                    section: link.section,
                    section_position: link.section_position,
                    is_section_followed: link.is_section_followed,
                  }
                : {}),
            })),
            firstVisibleTimestamp: props.firstVisibleTimestamp,
          })
        );
        this.impressionCardGuids = cards.map(link => link.id);
      }
    }
  }

  // This checks if the given cards are the same as those in the last loaded content ping.
  // If so, it should not send the same loaded content ping again.
  _needsLoadedContent(cards) {
    if (
      !this.loadedContentGuids ||
      this.loadedContentGuids.length !== cards.length
    ) {
      return true;
    }

    for (let i = 0; i < cards.length; i++) {
      if (cards[i].id !== this.loadedContentGuids[i]) {
        return true;
      }
    }

    return false;
  }

  _dispatchLoadedContent() {
    const { props } = this;
    const cards = props.rows;

    if (this._needsLoadedContent(cards)) {
      props.dispatch(
        ac.DiscoveryStreamLoadedContent({
          source: props.source.toUpperCase(),
          tiles: cards.map(link => ({ id: link.id, pos: link.pos })),
        })
      );
      this.loadedContentGuids = cards.map(link => link.id);
    }
  }

  setImpressionObserverOrAddListener() {
    const { props } = this;

    if (!props.dispatch) {
      return;
    }

    if (props.document.visibilityState === VISIBLE) {
      // Send the loaded content ping once the page is visible.
      this._dispatchLoadedContent();
      this.setImpressionObserver();
    } else {
      // We should only ever send the latest impression stats ping, so remove any
      // older listeners.
      if (this._onVisibilityChange) {
        props.document.removeEventListener(
          VISIBILITY_CHANGE_EVENT,
          this._onVisibilityChange
        );
      }

      this._onVisibilityChange = () => {
        if (props.document.visibilityState === VISIBLE) {
          // Send the loaded content ping once the page is visible.
          this._dispatchLoadedContent();
          this.setImpressionObserver();
          props.document.removeEventListener(
            VISIBILITY_CHANGE_EVENT,
            this._onVisibilityChange
          );
        }
      };
      props.document.addEventListener(
        VISIBILITY_CHANGE_EVENT,
        this._onVisibilityChange
      );
    }
  }

  /**
   * Set an impression observer for the wrapped component. It makes use of
   * the Intersection Observer API to detect if the wrapped component is
   * visible with a desired ratio, and only sends impression if that's the case.
   *
   * See more details about Intersection Observer API at:
   * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
   */
  setImpressionObserver() {
    const { props } = this;

    if (!props.rows.length) {
      return;
    }

    this._handleIntersect = entries => {
      if (
        entries.some(
          entry =>
            entry.isIntersecting &&
            entry.intersectionRatio >= INTERSECTION_RATIO
        )
      ) {
        this._dispatchImpressionStats();
        this.impressionObserver.unobserve(this.refs.impression);
      }
    };

    const options = { threshold: INTERSECTION_RATIO };
    this.impressionObserver = new props.IntersectionObserver(
      this._handleIntersect,
      options
    );
    this.impressionObserver.observe(this.refs.impression);
  }

  componentDidMount() {
    if (this.props.rows.length) {
      this.setImpressionObserverOrAddListener();
    }
  }

  componentWillUnmount() {
    if (this._handleIntersect && this.impressionObserver) {
      this.impressionObserver.unobserve(this.refs.impression);
    }
    if (this._onVisibilityChange) {
      this.props.document.removeEventListener(
        VISIBILITY_CHANGE_EVENT,
        this._onVisibilityChange
      );
    }
  }

  render() {
    return (
      <div ref={"impression"} className="impression-observer">
        {this.props.children}
      </div>
    );
  }
}

ImpressionStats.defaultProps = {
  IntersectionObserver: globalThis.IntersectionObserver,
  document: globalThis.document,
  rows: [],
  source: "",
};
