/* 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 https://mozilla.org/MPL/2.0/. */

import { render, fireEvent, act } from "@testing-library/react";
import { Provider } from "react-redux";
import { combineReducers, createStore } from "redux";
import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs";
import { actionTypes as at } from "common/Actions.mjs";
import { Crossword } from "content-src/components/Widgets/Crossword/Crossword";

// Origin of the endpoint in baseState; the component only accepts inbound
// messages from, and targets outbound commands at, this exact origin.
const MERINO_ORIGIN =
  "https://prod-games-particle.merino.prod.webservices.mozgcp.net";

const baseState = {
  ...INITIAL_STATE,
  Prefs: {
    ...INITIAL_STATE.Prefs,
    values: {
      ...INITIAL_STATE.Prefs.values,
      "widgets.system.enabled": true,
      "widgets.enabled": true,
      "widgets.system.crossword.enabled": true,
      "widgets.crossword.enabled": true,
      "widgets.crossword.size": "",
      "widgets.crossword.endpoint":
        "https://prod-games-particle.merino.prod.webservices.mozgcp.net/index.html",
    },
  },
};

function WrapWithProvider({ children, state = baseState }) {
  const store = createStore(combineReducers(reducers), state);
  return <Provider store={store}>{children}</Provider>;
}

function renderCrossword({
  state = baseState,
  dispatch = jest.fn(),
  handleUserInteraction = jest.fn(),
  widgetsMayBeMaximized = true,
} = {}) {
  const widgetEnabledMap = { crossword: true };
  const utils = render(
    <WrapWithProvider state={state}>
      <Crossword
        dispatch={dispatch}
        handleUserInteraction={handleUserInteraction}
        widgetsMayBeMaximized={widgetsMayBeMaximized}
        widgetEnabledMap={widgetEnabledMap}
      />
    </WrapWithProvider>
  );
  return { ...utils, dispatch, handleUserInteraction };
}

// Dispatch a postMessage-style event at the window as if it came from the
// widget iframe. jsdom does not honor `source` from the MessageEvent init
// dict, so it is forced onto the instance. `origin` and `source` default to
// the trusted iframe values but can be overridden to exercise the filters.
function postWidgetMessage(
  container,
  { data, origin = MERINO_ORIGIN, source }
) {
  const frame = container.querySelector("iframe.crossword-frame");
  const event = new MessageEvent("message", { data, origin });
  Object.defineProperty(event, "source", {
    configurable: true,
    value: source === undefined ? frame.contentWindow : source,
  });
  act(() => {
    window.dispatchEvent(event);
  });
}

function validMessage(type, payload) {
  return { channel: "crossword_widget", type, payload };
}

describe("<Crossword>", () => {
  describe("rendering", () => {
    it("renders the article root with the default medium size class", () => {
      const { container } = renderCrossword();
      const root = container.querySelector("article.crossword");
      expect(root).toBeInTheDocument();
      expect(root).toHaveClass("widget");
      expect(root).toHaveClass("col-4");
      expect(root).toHaveClass("medium-widget");
    });

    it("applies the size from the size pref when set", () => {
      const state = {
        ...baseState,
        Prefs: {
          ...baseState.Prefs,
          values: {
            ...baseState.Prefs.values,
            "widgets.crossword.size": "large",
          },
        },
      };
      const { container } = renderCrossword({ state });
      expect(container.querySelector("article.crossword")).toHaveClass(
        "large-widget"
      );
    });

    it("renders the size submenu only when widgetsMayBeMaximized", () => {
      const { container, rerender } = renderCrossword({
        widgetsMayBeMaximized: true,
      });
      expect(
        container.querySelector("#crossword-size-submenu")
      ).toBeInTheDocument();

      rerender(
        <WrapWithProvider state={baseState}>
          <Crossword
            dispatch={jest.fn()}
            widgetsMayBeMaximized={false}
            widgetEnabledMap={{ crossword: true }}
          />
        </WrapWithProvider>
      );
      expect(
        container.querySelector("#crossword-size-submenu")
      ).not.toBeInTheDocument();
    });
  });

  describe("hide action", () => {
    it("dispatches SET_PREF and WIDGETS_ENABLED when hide is clicked", () => {
      const { container, dispatch } = renderCrossword();
      const hideItem = container.querySelector(
        "panel-item[data-l10n-id='newtab-widget-menu-hide']"
      );
      fireEvent.click(hideItem);

      const setPrefCall = dispatch.mock.calls.find(
        ([action]) =>
          action?.type === at.SET_PREF &&
          action.data?.name === "widgets.crossword.enabled"
      );
      expect(setPrefCall?.[0].data.value).toBe(false);

      const enabledCall = dispatch.mock.calls.find(
        ([action]) => action?.type === at.WIDGETS_ENABLED
      );
      expect(enabledCall?.[0].data).toMatchObject({
        widget_name: "crossword",
        widget_source: "context_menu",
        enabled: false,
      });
    });
  });

  describe("learn more action", () => {
    it("dispatches OPEN_LINK with the shared SUMO URL", () => {
      const { container, dispatch } = renderCrossword();
      const learnMoreItem = container.querySelector("panel-item.learn-more");
      fireEvent.click(learnMoreItem);

      const openLinkCall = dispatch.mock.calls.find(
        ([action]) => action?.type === at.OPEN_LINK
      );
      expect(openLinkCall?.[0].data.url).toBe(
        "https://support.mozilla.org/kb/firefox-new-tab-widgets"
      );
    });

    it("dispatches a learn_more WIDGETS_USER_EVENT", () => {
      const { container, dispatch } = renderCrossword();
      fireEvent.click(container.querySelector("panel-item.learn-more"));

      const userEventCall = dispatch.mock.calls.find(
        ([action]) =>
          action?.type === at.WIDGETS_USER_EVENT &&
          action.data?.user_action === "learn_more"
      );
      expect(userEventCall?.[0].data).toMatchObject({
        widget_name: "crossword",
        widget_source: "context_menu",
        user_action: "learn_more",
        widget_size: "medium",
      });
    });
  });

  describe("impression telemetry", () => {
    let originalIntersectionObserver;
    let observerInstances;

    beforeEach(() => {
      observerInstances = [];
      originalIntersectionObserver = global.IntersectionObserver;
      global.IntersectionObserver = class MockIntersectionObserver {
        constructor(callback) {
          this.callback = callback;
          this.observed = [];
          observerInstances.push(this);
        }
        observe(el) {
          this.observed.push(el);
        }
        unobserve() {}
        disconnect() {}
      };
    });

    afterEach(() => {
      global.IntersectionObserver = originalIntersectionObserver;
    });

    it("dispatches WIDGETS_IMPRESSION only once even when intersection fires repeatedly", () => {
      const { dispatch } = renderCrossword();
      const [observer] = observerInstances;
      const [target] = observer.observed;

      observer.callback([{ isIntersecting: true, target }], observer);
      observer.callback([{ isIntersecting: true, target }], observer);

      const impressions = dispatch.mock.calls.filter(
        ([action]) => action?.type === at.WIDGETS_IMPRESSION
      );
      expect(impressions).toHaveLength(1);
      expect(impressions[0][0].data).toMatchObject({
        widget_name: "crossword",
        widget_size: "medium",
      });
    });
  });

  describe("crossword iframe", () => {
    it("renders a sandboxed iframe pointing at the resolved endpoint", () => {
      const { container } = renderCrossword();
      const frame = container.querySelector("iframe.crossword-frame");
      expect(frame).toBeInTheDocument();
      expect(frame).toHaveAttribute(
        "sandbox",
        "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
      );
      expect(frame).toHaveAttribute(
        "src",
        "https://prod-games-particle.merino.prod.webservices.mozgcp.net/index.html"
      );
    });

    it("prefers the trainhopConfig endpoint over the raw pref", () => {
      const trainhopEndpoint = "https://example.com/crossword/index.html";
      const state = {
        ...baseState,
        Prefs: {
          ...baseState.Prefs,
          values: {
            ...baseState.Prefs.values,
            trainhopConfig: {
              widgets: { crosswordEndpoint: trainhopEndpoint },
            },
          },
        },
      };
      const { container } = renderCrossword({ state });
      expect(container.querySelector("iframe.crossword-frame")).toHaveAttribute(
        "src",
        trainhopEndpoint
      );
    });
  });

  describe("change size action", () => {
    it("dispatches SET_PREF and a change_size WIDGETS_USER_EVENT when a size is picked", () => {
      const { container, dispatch } = renderCrossword();
      const largeSizeItem = container.querySelector(
        "#crossword-size-submenu panel-item[data-size='large']"
      );
      fireEvent.click(largeSizeItem);

      const setPrefCall = dispatch.mock.calls.find(
        ([action]) =>
          action?.type === at.SET_PREF &&
          action.data?.name === "widgets.crossword.size"
      );
      expect(setPrefCall?.[0].data.value).toBe("large");

      const userEventCall = dispatch.mock.calls.find(
        ([action]) =>
          action?.type === at.WIDGETS_USER_EVENT &&
          action.data?.user_action === "change_size"
      );
      expect(userEventCall?.[0].data).toMatchObject({
        widget_name: "crossword",
        widget_source: "context_menu",
        user_action: "change_size",
        action_value: "large",
        widget_size: "large",
      });
    });
  });

  describe("menu actions — postMessage send", () => {
    let originalCrypto;

    beforeEach(() => {
      originalCrypto = global.crypto;
      Object.defineProperty(global, "crypto", {
        configurable: true,
        value: { randomUUID: () => "test-request-id" },
      });
    });

    afterEach(() => {
      Object.defineProperty(global, "crypto", {
        configurable: true,
        value: originalCrypto,
      });
    });

    it("posts show_all_clues to the iframe on the crossword channel with the merino targetOrigin", () => {
      const { container } = renderCrossword();
      const frame = container.querySelector("iframe.crossword-frame");
      const postSpy = jest
        .spyOn(frame.contentWindow, "postMessage")
        .mockImplementation(() => {});

      fireEvent.click(container.querySelector("panel-item.show-all-clues"));

      expect(postSpy).toHaveBeenCalledTimes(1);
      const [[message, targetOrigin]] = postSpy.mock.calls;
      expect(message).toMatchObject({
        channel: "crossword_widget",
        type: "menu_action",
        action: "show_all_clues",
      });
      expect(message.requestId).toMatch(/^firefox-menu-/);
      expect(targetOrigin).toBe(MERINO_ORIGIN);
      expect(targetOrigin).not.toBe("*");
    });

    it("posts reveal_grid when Solve puzzle is clicked", () => {
      const { container } = renderCrossword();
      const frame = container.querySelector("iframe.crossword-frame");
      const postSpy = jest
        .spyOn(frame.contentWindow, "postMessage")
        .mockImplementation(() => {});

      fireEvent.click(container.querySelector("panel-item.solve-puzzle"));

      expect(postSpy).toHaveBeenCalledTimes(1);
      expect(postSpy.mock.calls[0][0]).toMatchObject({
        type: "menu_action",
        action: "reveal_grid",
      });
    });

    it("dispatches a menu_action WIDGETS_USER_EVENT carrying the action", () => {
      const { container, dispatch } = renderCrossword();
      const frame = container.querySelector("iframe.crossword-frame");
      jest
        .spyOn(frame.contentWindow, "postMessage")
        .mockImplementation(() => {});

      fireEvent.click(container.querySelector("panel-item.show-all-clues"));

      const userEventCall = dispatch.mock.calls.find(
        ([action]) =>
          action?.type === at.WIDGETS_USER_EVENT &&
          action.data?.user_action === "menu_action"
      );
      expect(userEventCall?.[0].data).toMatchObject({
        widget_name: "crossword",
        widget_source: "context_menu",
        user_action: "menu_action",
        action_value: "show_all_clues",
        widget_size: "medium",
      });
    });

    it("does not post when the endpoint is malformed (no resolvable origin)", () => {
      const state = {
        ...baseState,
        Prefs: {
          ...baseState.Prefs,
          values: {
            ...baseState.Prefs.values,
            "widgets.crossword.endpoint": "not-a-valid-url",
          },
        },
      };
      const { container } = renderCrossword({ state });
      const frame = container.querySelector("iframe.crossword-frame");
      const postSpy = jest
        .spyOn(frame.contentWindow, "postMessage")
        .mockImplementation(() => {});

      fireEvent.click(container.querySelector("panel-item.show-all-clues"));

      expect(postSpy).not.toHaveBeenCalled();
    });
  });

  describe("inbound widget events + security filtering", () => {
    it("dispatches a puzzle_completed WIDGETS_USER_EVENT for a valid message", () => {
      const { container, dispatch } = renderCrossword();

      postWidgetMessage(container, {
        data: validMessage("puzzle_completed", {
          elapsedTimeSeconds: 42,
          hintsTaken: 3,
        }),
      });

      const call = dispatch.mock.calls.find(
        ([action]) =>
          action?.type === at.WIDGETS_USER_EVENT &&
          action.data?.user_action === "puzzle_completed"
      );
      expect(call?.[0].data).toMatchObject({
        widget_name: "crossword",
        widget_source: "iframe",
        user_action: "puzzle_completed",
        action_value: 3,
        widget_size: "medium",
      });
    });

    it("dispatches an interaction WIDGETS_USER_EVENT for a valid message", () => {
      const { container, dispatch } = renderCrossword();

      postWidgetMessage(container, {
        data: validMessage("interaction", { action: "cell_focus" }),
      });

      const call = dispatch.mock.calls.find(
        ([action]) =>
          action?.type === at.WIDGETS_USER_EVENT &&
          action.data?.user_action === "interaction"
      );
      expect(call?.[0].data).toMatchObject({
        widget_source: "iframe",
        user_action: "interaction",
        action_value: "cell_focus",
      });
    });

    it("ignores messages from an unexpected origin", () => {
      const { container, dispatch } = renderCrossword();

      postWidgetMessage(container, {
        data: validMessage("interaction", { action: "cell_focus" }),
        origin: "https://evil.example.com",
      });

      expect(
        dispatch.mock.calls.some(
          ([action]) => action?.type === at.WIDGETS_USER_EVENT
        )
      ).toBe(false);
    });

    it("ignores messages whose source is not the widget iframe", () => {
      const { container, dispatch } = renderCrossword();

      postWidgetMessage(container, {
        data: validMessage("interaction", { action: "cell_focus" }),
        source: window,
      });

      expect(
        dispatch.mock.calls.some(
          ([action]) => action?.type === at.WIDGETS_USER_EVENT
        )
      ).toBe(false);
    });

    it("ignores messages missing the crossword channel", () => {
      const { container, dispatch } = renderCrossword();

      postWidgetMessage(container, {
        data: {
          type: "interaction",
          payload: { action: "cell_focus" },
        },
      });

      expect(
        dispatch.mock.calls.some(
          ([action]) => action?.type === at.WIDGETS_USER_EVENT
        )
      ).toBe(false);
    });

    it("ignores events whose payload fails validation", () => {
      const { container, dispatch } = renderCrossword();

      postWidgetMessage(container, {
        data: validMessage("puzzle_completed", { hintsTaken: "three" }),
      });

      expect(
        dispatch.mock.calls.some(
          ([action]) => action?.type === at.WIDGETS_USER_EVENT
        )
      ).toBe(false);
    });

    it("ignores puzzle_completed with a non-whole hintsTaken", () => {
      const { container, dispatch } = renderCrossword();

      postWidgetMessage(container, {
        data: validMessage("puzzle_completed", {
          elapsedTimeSeconds: 42,
          hintsTaken: -1,
        }),
      });

      expect(
        dispatch.mock.calls.some(
          ([action]) => action?.type === at.WIDGETS_USER_EVENT
        )
      ).toBe(false);
    });
  });

  describe("completion state", () => {
    it("hides Solve puzzle but keeps Show clues after a valid puzzle_completed", () => {
      const { container } = renderCrossword();
      expect(
        container.querySelector("panel-item.solve-puzzle")
      ).toBeInTheDocument();
      expect(
        container.querySelector("panel-item.show-all-clues")
      ).toBeInTheDocument();

      postWidgetMessage(container, {
        data: validMessage("puzzle_completed", {
          elapsedTimeSeconds: 42,
          hintsTaken: 3,
        }),
      });

      expect(
        container.querySelector("panel-item.solve-puzzle")
      ).not.toBeInTheDocument();
      expect(
        container.querySelector("panel-item.show-all-clues")
      ).toBeInTheDocument();
    });

    it("keeps Solve puzzle when a completion message fails validation", () => {
      const { container } = renderCrossword();

      postWidgetMessage(container, {
        data: validMessage("puzzle_completed", { hintsTaken: "three" }),
      });

      expect(
        container.querySelector("panel-item.solve-puzzle")
      ).toBeInTheDocument();
    });
  });

  describe("puzzle_state sizing", () => {
    function largeState() {
      return {
        ...baseState,
        Prefs: {
          ...baseState.Prefs,
          values: {
            ...baseState.Prefs.values,
            "widgets.crossword.size": "large",
          },
        },
      };
    }

    it("forces the large layout while the puzzle is in_progress", () => {
      const { container } = renderCrossword();
      const root = container.querySelector("article.crossword");
      expect(root).toHaveClass("medium-widget");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", {
          crosswordId: "gid",
          state: "in_progress",
          sourceLabel: "live",
        }),
      });

      expect(root).toHaveClass("large-widget");
      expect(root).not.toHaveClass("medium-widget");
    });

    it("stays large through the completed state after solving", () => {
      const { container } = renderCrossword();
      const root = container.querySelector("article.crossword");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "in_progress" }),
      });
      expect(root).toHaveClass("large-widget");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "completed" }),
      });
      expect(root).toHaveClass("large-widget");
      expect(root).not.toHaveClass("medium-widget");
    });

    it("stays large on puzzle_completed after solving", () => {
      const { container } = renderCrossword();
      const root = container.querySelector("article.crossword");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "in_progress" }),
      });
      expect(root).toHaveClass("large-widget");

      postWidgetMessage(container, {
        data: validMessage("puzzle_completed", {
          elapsedTimeSeconds: 42,
          hintsTaken: 1,
        }),
      });
      expect(root).toHaveClass("large-widget");
    });

    it("stays medium when loading directly into completed (returning card)", () => {
      const { container } = renderCrossword();
      const root = container.querySelector("article.crossword");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "completed" }),
      });

      expect(root).toHaveClass("medium-widget");
      expect(root).not.toHaveClass("large-widget");
    });

    it("returns to medium when a new puzzle goes back to the intro state", () => {
      const { container } = renderCrossword();
      const root = container.querySelector("article.crossword");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "in_progress" }),
      });
      expect(root).toHaveClass("large-widget");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "intro" }),
      });
      expect(root).toHaveClass("medium-widget");
    });

    it("does not force large when widgetsMayBeMaximized is false", () => {
      const { container } = renderCrossword({ widgetsMayBeMaximized: false });
      const root = container.querySelector("article.crossword");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "in_progress" }),
      });

      expect(root).toHaveClass("medium-widget");
      expect(root).not.toHaveClass("large-widget");
    });

    it("keeps a user-chosen large size on the intro state", () => {
      const { container } = renderCrossword({ state: largeState() });
      const root = container.querySelector("article.crossword");
      expect(root).toHaveClass("large-widget");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "intro" }),
      });

      expect(root).toHaveClass("large-widget");
    });

    it("ignores a puzzle_state with an unknown state value", () => {
      const { container } = renderCrossword();
      const root = container.querySelector("article.crossword");

      postWidgetMessage(container, {
        data: validMessage("puzzle_state", { state: "bogus" }),
      });

      expect(root).toHaveClass("medium-widget");
      expect(root).not.toHaveClass("large-widget");
    });
  });

  describe("powered by particle action", () => {
    it("dispatches OPEN_LINK to particle.news", () => {
      const { container, dispatch } = renderCrossword();
      fireEvent.click(
        container.querySelector("panel-item.powered-by-particle")
      );

      const openLinkCall = dispatch.mock.calls.find(
        ([action]) => action?.type === at.OPEN_LINK
      );
      expect(openLinkCall?.[0].data.url).toBe("https://particle.news");
    });

    it("dispatches a powered_by_particle WIDGETS_USER_EVENT", () => {
      const { container, dispatch } = renderCrossword();
      fireEvent.click(
        container.querySelector("panel-item.powered-by-particle")
      );

      const userEventCall = dispatch.mock.calls.find(
        ([action]) =>
          action?.type === at.WIDGETS_USER_EVENT &&
          action.data?.user_action === "powered_by_particle"
      );
      expect(userEventCall?.[0].data).toMatchObject({
        widget_name: "crossword",
        widget_source: "context_menu",
        user_action: "powered_by_particle",
        widget_size: "medium",
      });
    });
  });

  describe("menu strings", () => {
    it("hardcodes the crossword-specific items and keeps shared chrome ids", () => {
      const { container } = renderCrossword();
      const menu = container.querySelector("#crossword-context-menu");

      // Crossword-specific strings are hardcoded inline (English-only).
      expect(menu.querySelector("panel-item.show-all-clues")).toHaveTextContent(
        "Show clues"
      );
      expect(menu.querySelector("panel-item.solve-puzzle")).toHaveTextContent(
        "Solve puzzle"
      );
      expect(
        menu.querySelector("panel-item.powered-by-particle")
      ).toHaveTextContent("Powered by Particle");

      // Generic chrome keeps the shared, translated Fluent ids.
      expect(
        menu.querySelector(
          "span[data-l10n-id='newtab-widget-menu-change-size']"
        )
      ).toBeInTheDocument();
      expect(
        container.querySelector(
          "#crossword-size-submenu panel-item[data-l10n-id='newtab-widget-size-medium']"
        )
      ).toBeInTheDocument();
      expect(
        container.querySelector(
          "#crossword-size-submenu panel-item[data-l10n-id='newtab-widget-size-large']"
        )
      ).toBeInTheDocument();
      expect(
        menu.querySelector("panel-item[data-l10n-id='newtab-widget-menu-hide']")
      ).toBeInTheDocument();

      expect(menu.querySelector("hr")).toBeInTheDocument();
    });
  });

  describe("new badge", () => {
    it("renders the New badge before the title when not interacted with", () => {
      const { container } = renderCrossword();
      const badge = container.querySelector("moz-badge.crossword-new-badge");
      expect(badge).toBeInTheDocument();
      // No type="new": that looks up the unregistered moz-badge-new2 string.
      expect(badge).not.toHaveAttribute("type");
      expect(badge).toHaveAttribute(
        "data-l10n-id",
        "newtab-widget-lists-label-new"
      );
      // The badge renders immediately before the title.
      expect(badge.nextElementSibling).toBe(
        container.querySelector("h3.newtab-crossword-title")
      );
    });

    it("hides the New badge once the interaction pref is set", () => {
      const state = {
        ...baseState,
        Prefs: {
          ...baseState.Prefs,
          values: {
            ...baseState.Prefs.values,
            "widgets.crossword.interaction": true,
          },
        },
      };
      const { container } = renderCrossword({ state });
      expect(
        container.querySelector("moz-badge.crossword-new-badge")
      ).not.toBeInTheDocument();
      expect(
        container.querySelector("h3.newtab-crossword-title")
      ).toBeInTheDocument();
    });
  });

  describe("interaction pref", () => {
    it("flips the interaction pref via handleUserInteraction on a menu action", () => {
      const handleUserInteraction = jest.fn();
      const { container } = renderCrossword({ handleUserInteraction });
      fireEvent.click(container.querySelector("panel-item.learn-more"));
      expect(handleUserInteraction).toHaveBeenCalledWith("crossword");
    });

    it("does NOT flip the interaction pref when hiding the widget", () => {
      const handleUserInteraction = jest.fn();
      const { container } = renderCrossword({ handleUserInteraction });
      fireEvent.click(
        container.querySelector(
          "panel-item[data-l10n-id='newtab-widget-menu-hide']"
        )
      );
      expect(handleUserInteraction).not.toHaveBeenCalled();
    });
  });
});
