import { AutofillPort } from "../enums/autofill-port.enum";
import { triggerPortOnDisconnectEvent } from "../spec/testing-utils";

import { logoIcon, logoLockedIcon } from "./svg-icons";

import {
  buildSvgDomElement,
  debounce,
  generateRandomCustomElementName,
  isReadonlyOrDisabledFormFieldElement,
  isSubFramePositioningMessageData,
  sendExtensionMessage,
  setElementStyles,
  setupAutofillInitDisconnectAction,
  setupExtensionDisconnectAction,
} from "./index";

describe("buildSvgDomElement", () => {
  it("returns an SVG DOM element", () => {
    const builtSVG = buildSvgDomElement(logoIcon);
    const builtSVGAriaVisible = buildSvgDomElement(logoLockedIcon, false);

    expect(builtSVG.tagName).toEqual("svg");
    expect(builtSVG.getAttribute("aria-hidden")).toEqual("true");
    expect(builtSVGAriaVisible.tagName).toEqual("svg");
    expect(builtSVGAriaVisible.getAttribute("aria-hidden")).toEqual("false");
  });
});

describe("generateRandomCustomElementName", () => {
  it("returns a randomized value", async () => {
    let generatedValue = "";

    expect(generatedValue).toHaveLength(0);

    generatedValue = generateRandomCustomElementName();

    expect(generatedValue.length).toBeGreaterThan(0);
  });
});

describe("sendExtensionMessage", () => {
  it("sends a message to the extension", async () => {
    const extensionMessagePromise = sendExtensionMessage("some-extension-message");

    // Jest doesn't give anyway to select the typed overload of "sendMessage",
    // a cast is needed to get the correct spy type.
    const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage") as unknown as jest.SpyInstance<
      void,
      [message: string, responseCallback: (response: string) => void],
      unknown
    >;

    expect(sendMessageSpy).toHaveBeenCalled();

    const [latestCall] = sendMessageSpy.mock.calls;
    const responseCallback = latestCall[1];

    responseCallback("sendMessageResponse");

    const response = await extensionMessagePromise;
    expect(response).toEqual("sendMessageResponse");
  });
});

describe("setElementStyles", () => {
  const passedRules = { backgroundColor: "hotpink", color: "cyan" };
  const expectedCSSRuleString = "background-color: hotpink; color: cyan;";
  const expectedImportantCSSRuleString =
    "background-color: hotpink !important; color: cyan !important;";

  it("sets the passed styles to the passed HTMLElement", async () => {
    const domParser = new DOMParser();
    const testDivDOM = domParser.parseFromString(
      "<div>This is an unexciting div.</div>",
      "text/html",
    );
    const testDiv = testDivDOM.querySelector("div");

    expect(testDiv.getAttribute("style")).toEqual(null);

    setElementStyles(testDiv, passedRules);

    expect(testDiv.getAttribute("style")).toEqual(expectedCSSRuleString);
  });

  it("sets the passed styles with !important flag to the passed HTMLElement", () => {
    const domParser = new DOMParser();
    const testDivDOM = domParser.parseFromString(
      "<div>This is an unexciting div.</div>",
      "text/html",
    );
    const testDiv = testDivDOM.querySelector("div");

    expect(testDiv.style.cssText).toEqual("");

    setElementStyles(testDiv, passedRules, true);

    expect(testDiv.style.cssText).toEqual(expectedImportantCSSRuleString);
  });

  it("makes no changes when no element is passed", () => {
    const domParser = new DOMParser();
    const testDivDOM = domParser.parseFromString(
      "<div>This is an unexciting div.</div>",
      "text/html",
    );
    const testDiv = testDivDOM.querySelector("div");

    expect(testDiv.style.cssText).toEqual("");

    setElementStyles(testDiv, passedRules);

    expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);

    setElementStyles(undefined, passedRules, true);

    expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
  });

  it("makes no changes when no CSS rules are passed", () => {
    const domParser = new DOMParser();
    const testDivDOM = domParser.parseFromString(
      "<div>This is an unexciting div.</div>",
      "text/html",
    );
    const testDiv = testDivDOM.querySelector("div");

    expect(testDiv.style.cssText).toEqual("");

    setElementStyles(testDiv, passedRules);

    expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);

    setElementStyles(testDiv, {}, true);

    expect(testDiv.style.cssText).toEqual(expectedCSSRuleString);
  });
});

describe("setupExtensionDisconnectAction", () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  it("connects a port to the extension background and sets up an onDisconnect listener", () => {
    const onDisconnectCallback = jest.fn();
    let port: chrome.runtime.Port;
    jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
      port = {
        onDisconnect: {
          addListener: onDisconnectCallback,
          removeListener: jest.fn(),
        },
      } as unknown as chrome.runtime.Port;

      return port;
    });

    setupExtensionDisconnectAction(onDisconnectCallback);

    expect(chrome.runtime.connect).toHaveBeenCalledWith({
      name: AutofillPort.InjectedScript,
    });
    expect(port.onDisconnect.addListener).toHaveBeenCalledWith(expect.any(Function));
  });
});

describe("setupAutofillInitDisconnectAction", () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  it("skips setting up the extension disconnect action if the bitwardenAutofillInit object is not populated", () => {
    const onDisconnectCallback = jest.fn();
    window.bitwardenAutofillInit = undefined;
    const portConnectSpy = jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
      return {
        onDisconnect: {
          addListener: onDisconnectCallback,
          removeListener: jest.fn(),
        },
      } as unknown as chrome.runtime.Port;
    });

    setupAutofillInitDisconnectAction(window);

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

  it("destroys the autofill init instance when the port is disconnected", () => {
    let port: chrome.runtime.Port;
    const autofillInitDestroy: CallableFunction = jest.fn();
    window.bitwardenAutofillInit = {
      destroy: autofillInitDestroy,
    } as any;
    jest.spyOn(chrome.runtime, "connect").mockImplementation(() => {
      port = {
        onDisconnect: {
          addListener: jest.fn(),
          removeListener: jest.fn(),
        },
      } as unknown as chrome.runtime.Port;

      return port;
    });

    setupAutofillInitDisconnectAction(window);
    triggerPortOnDisconnectEvent(port as chrome.runtime.Port);

    expect(chrome.runtime.connect).toHaveBeenCalled();
    expect(port.onDisconnect.addListener).toHaveBeenCalled();
    expect(autofillInitDestroy).toHaveBeenCalled();
    expect(window.bitwardenAutofillInit).toBeUndefined();
  });
});

describe("debounce", () => {
  const debouncedFunction = jest.fn();
  const debounced = debounce(debouncedFunction, 100);

  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.clearAllTimers();
  });

  afterAll(() => {
    jest.useRealTimers();
  });

  it("does not call the method until the delay is complete", () => {
    debounced();
    jest.advanceTimersByTime(50);
    expect(debouncedFunction).not.toHaveBeenCalled();
  });

  it("calls the method a single time when the debounce is triggered multiple times", () => {
    debounced();
    debounced();
    debounced();
    jest.advanceTimersByTime(100);

    expect(debouncedFunction).toHaveBeenCalledTimes(1);
  });
});

describe("isReadonlyOrDisabledFormFieldElement", () => {
  it("returns false for an enabled, editable text input", () => {
    document.body.innerHTML = `<input type="text" id="field" />`;
    expect(
      isReadonlyOrDisabledFormFieldElement(document.getElementById("field") as HTMLInputElement),
    ).toBe(false);
  });

  it("returns true when DOM or cached flags indicate the field is not writable", () => {
    const expectTrue = (html: string, meta?: { readonly?: boolean; disabled?: boolean }) => {
      document.body.innerHTML = html;
      expect(
        isReadonlyOrDisabledFormFieldElement(
          document.getElementById("field") as HTMLInputElement,
          meta,
        ),
      ).toBe(true);
    };

    expectTrue(`<input type="text" id="field" disabled />`);
    expectTrue(`<input type="text" id="field" readonly />`);
    expectTrue(`<input type="text" id="field" aria-readonly="true" />`);
    expectTrue(`<input type="text" id="field" />`, { readonly: true });
    expectTrue(`<input type="text" id="field" />`, { disabled: true });

    document.body.innerHTML = `<textarea id="field"></textarea>`;
    const textarea = document.getElementById("field") as HTMLTextAreaElement;
    textarea.readOnly = true;
    expect(isReadonlyOrDisabledFormFieldElement(textarea)).toBe(true);
  });
});

describe("isSubFramePositioningMessageData", () => {
  const validSubFrameData = { top: 0, left: 0, subFrameDepth: 0 };

  it("returns true for a minimal payload with the optional fields omitted", () => {
    expect(isSubFramePositioningMessageData({ subFrameData: validSubFrameData })).toBe(true);
  });

  it("returns true when the optional frameId and parentFrameIds are present and well-typed", () => {
    expect(
      isSubFramePositioningMessageData({
        subFrameData: { top: 1, left: 2, subFrameDepth: 3, frameId: 4, parentFrameIds: [0, 1, 2] },
      }),
    ).toBe(true);
  });

  it("returns true when parentFrameIds is an empty array", () => {
    expect(
      isSubFramePositioningMessageData({
        subFrameData: { ...validSubFrameData, parentFrameIds: [] },
      }),
    ).toBe(true);
  });

  it("returns true when the numeric fields are negative", () => {
    expect(
      isSubFramePositioningMessageData({ subFrameData: { top: -10, left: -20, subFrameDepth: 0 } }),
    ).toBe(true);
  });

  it("returns true for a payload sourced from JSON.parse (the realistic postMessage shape)", () => {
    const data = JSON.parse(
      '{"subFrameData":{"top":1,"left":2,"subFrameDepth":3,"frameId":4,"parentFrameIds":[0]}}',
    );
    expect(isSubFramePositioningMessageData(data)).toBe(true);
  });

  it("ignores unknown extra properties on subFrameData (only url is special-cased)", () => {
    expect(
      isSubFramePositioningMessageData({
        subFrameData: { ...validSubFrameData, isCrossOriginSubframe: true, unexpected: "ignored" },
      }),
    ).toBe(true);
  });

  describe("rejecting a subFrameData that carries a url", () => {
    it("rejects an own url with a string value", () => {
      expect(
        isSubFramePositioningMessageData({
          subFrameData: { ...validSubFrameData, url: "https://example.com/secret?token=leak" },
        }),
      ).toBe(false);
    });

    it("rejects a url key even when its value is undefined", () => {
      expect(
        isSubFramePositioningMessageData({
          subFrameData: { ...validSubFrameData, url: undefined },
        }),
      ).toBe(false);
    });

    it("rejects a url inherited from the prototype chain", () => {
      const subFrameData = Object.assign(
        Object.create({ url: "https://example.com/secret" }),
        validSubFrameData,
      );
      expect(isSubFramePositioningMessageData({ subFrameData })).toBe(false);
    });

    it("rejects a url installed via setPrototypeOf", () => {
      const subFrameData = { ...validSubFrameData };
      Object.setPrototypeOf(subFrameData, { url: "https://example.com/secret" });
      expect(isSubFramePositioningMessageData({ subFrameData })).toBe(false);
    });

    it("rejects a url present in a JSON.parse-sourced payload", () => {
      const data = JSON.parse(
        '{"subFrameData":{"top":0,"left":0,"subFrameDepth":0,"url":"https://leak"}}',
      );
      expect(isSubFramePositioningMessageData(data)).toBe(false);
    });
  });

  it.each([
    ["null", null],
    ["undefined", undefined],
    ["a string", "calculateSubFramePositioning"],
    ["a number", 42],
    ["an array", [validSubFrameData]],
    ["an object without a subFrameData property", { command: "calculateSubFramePositioning" }],
    ["a null subFrameData", { subFrameData: null }],
    ["a non-object subFrameData", { subFrameData: "not-an-object" }],
    ["an array subFrameData", { subFrameData: [0, 1] }],
  ])("returns false when the data is %s", (_label, data) => {
    expect(isSubFramePositioningMessageData(data)).toBe(false);
  });

  it.each([
    ["top is missing", { left: 0, subFrameDepth: 0 }],
    ["left is missing", { top: 0, subFrameDepth: 0 }],
    ["subFrameDepth is missing", { top: 0, left: 0 }],
    ["top is not a number", { top: "0", left: 0, subFrameDepth: 0 }],
    ["left is not a number", { top: 0, left: "0", subFrameDepth: 0 }],
    ["subFrameDepth is not a number", { top: 0, left: 0, subFrameDepth: "0" }],
    ["top is NaN", { top: NaN, left: 0, subFrameDepth: 0 }],
    ["subFrameDepth is Infinity", { top: 0, left: 0, subFrameDepth: Infinity }],
    ["frameId is present but not a number", { top: 0, left: 0, subFrameDepth: 0, frameId: "1" }],
    ["frameId is null", { top: 0, left: 0, subFrameDepth: 0, frameId: null }],
    [
      "parentFrameIds is not an array",
      { top: 0, left: 0, subFrameDepth: 0, parentFrameIds: "0,1" },
    ],
    ["parentFrameIds is null", { top: 0, left: 0, subFrameDepth: 0, parentFrameIds: null }],
    [
      "parentFrameIds contains a non-number element",
      { top: 0, left: 0, subFrameDepth: 0, parentFrameIds: [0, "1"] },
    ],
    [
      "parentFrameIds contains NaN",
      { top: 0, left: 0, subFrameDepth: 0, parentFrameIds: [0, NaN] },
    ],
  ])("returns false when subFrameData %s", (_label, subFrameData) => {
    expect(isSubFramePositioningMessageData({ subFrameData })).toBe(false);
  });
});
