(function () {
  /*
  MIT LICENSE
  Copyright 2011 Jon Leighton
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
  associated documentation files (the "Software"), to deal in the Software without restriction,
  including without limitation the rights to use, copy, modify, merge, publish, distribute,
  sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
  furnished to do so, subject to the following conditions:
  The above copyright notice and this permission notice shall be included in all copies or substantial
  portions of the Software.

  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
  PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
  CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
  // From: https://gist.github.com/jonleighton/958841
  function base64ArrayBuffer(arrayBuffer) {
    var base64 = ''
    var encodings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'

    var bytes = new Uint8Array(arrayBuffer)
    var byteLength = bytes.byteLength
    var byteRemainder = byteLength % 3
    var mainLength = byteLength - byteRemainder

    var a, b, c, d
    var chunk

    // Main loop deals with bytes in chunks of 3
    for (var i = 0; i < mainLength; i = i + 3) {
      // Combine the three bytes into a single integer
      chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]

      // Use bitmasks to extract 6-bit segments from the triplet
      a = (chunk & 16515072) >> 18 // 16515072 = (2^6 - 1) << 18
      b = (chunk & 258048) >> 12 // 258048   = (2^6 - 1) << 12
      c = (chunk & 4032) >> 6 // 4032     = (2^6 - 1) << 6
      d = chunk & 63               // 63       = 2^6 - 1

      // Convert the raw binary segments to the appropriate ASCII encoding
      base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d]
    }

    // Deal with the remaining bytes and padding
    if (byteRemainder == 1) {
      chunk = bytes[mainLength]

      a = (chunk & 252) >> 2 // 252 = (2^6 - 1) << 2

      // Set the 4 least significant bits to zero
      b = (chunk & 3) << 4 // 3   = 2^2 - 1

      base64 += encodings[a] + encodings[b] + '=='
    } else if (byteRemainder == 2) {
      chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1]

      a = (chunk & 64512) >> 10 // 64512 = (2^6 - 1) << 10
      b = (chunk & 1008) >> 4 // 1008  = (2^6 - 1) << 4

      // Set the 2 least significant bits to zero
      c = (chunk & 15) << 2 // 15    = 2^4 - 1

      base64 += encodings[a] + encodings[b] + encodings[c] + '='
    }

    return base64
  }

  // Turn a base64 string into a blob. 
  // From https://gist.github.com/gauravmehla/7a7dfd87dd7d1b13697b6e894426615f
  function b64toBlob(b64Data, contentType, sliceSize) {
    var contentType = contentType || '';
    var sliceSize = sliceSize || 512;
    var byteCharacters = atob(b64Data);
    var byteArrays = [];
    for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) {
      var slice = byteCharacters.slice(offset, offset + sliceSize);
      var byteNumbers = new Array(slice.length);
      for (var i = 0; i < slice.length; i++) {
        byteNumbers[i] = slice.charCodeAt(i);
      }
      var byteArray = new Uint8Array(byteNumbers);
      byteArrays.push(byteArray);
    }
    return new Blob(byteArrays, { type: contentType });
  }

  function createBlackImageBase64(width, height) {
    // Create a canvas element
    var canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;

    // Get the context of the canvas
    var ctx = canvas.getContext('2d');

    // Fill the canvas with black color
    ctx.fillStyle = 'black';
    ctx.fillRect(0, 0, width, height);

    // Get the base64 encoded string
    var base64Image = canvas.toDataURL('image/png');

    return base64Image;
  }

  // Functions to be called within photopea context.
  // Start of photopea functions
  function pasteImage(base64image) {
    app.open(base64image, null, /* asSmart */ true);
    app.echoToOE("success");
  }

  function setLayerNames(names) {
    const layers = app.activeDocument.layers;
    if (layers.length !== names.length) {
      console.error("layer length does not match names length");
      echoToOE("error");
      return;
    }

    for (let i = 0; i < names.length; i++) {
      const layer = layers[i];
      layer.name = names[i];
    }
    app.echoToOE("success");
  }

  function removeLayersWithNames(names) {
    const layers = app.activeDocument.layers;
    for (let i = 0; i < layers.length; i++) {
      const layer = layers[i];
      if (names.includes(layer.name)) {
        layer.remove();
      }
    }
    app.echoToOE("success");
  }

  function getAllLayerNames() {
    const layers = app.activeDocument.layers;
    const names = [];
    for (let i = 0; i < layers.length; i++) {
      const layer = layers[i];
      names.push(layer.name);
    }
    app.echoToOE(JSON.stringify(names));
  }

  // Hides all layers except the current one, outputs the whole image, then restores the previous
  // layers state.
  function exportSelectedLayerOnly(format, layerName) {
    // Gets all layers recursively, including the ones inside folders.
    function getAllArtLayers(document) {
      let allArtLayers = [];

      for (let i = 0; i < document.layers.length; i++) {
        const currentLayer = document.layers[i];
        allArtLayers.push(currentLayer);
        if (currentLayer.typename === "LayerSet") {
          allArtLayers = allArtLayers.concat(getAllArtLayers(currentLayer));
        }
      }
      return allArtLayers;
    }

    function makeLayerVisible(layer) {
      let currentLayer = layer;
      while (currentLayer != app.activeDocument) {
        currentLayer.visible = true;
        if (currentLayer.parent.typename != 'Document') {
          currentLayer = currentLayer.parent;
        } else {
          break;
        }
      }
    }


    const allLayers = getAllArtLayers(app.activeDocument);
    // Make all layers except the currently selected one invisible, and store
    // their initial state.
    const layerStates = [];
    for (let i = 0; i < allLayers.length; i++) {
      const layer = allLayers[i];
      layerStates.push(layer.visible);
    }
    // Hide all layers to begin with
    for (let i = 0; i < allLayers.length; i++) {
      const layer = allLayers[i];
      layer.visible = false;
    }
    for (let i = 0; i < allLayers.length; i++) {
      const layer = allLayers[i];
      const selected = layer.name === layerName;
      if (selected) {
        makeLayerVisible(layer);
      }
    }
    app.activeDocument.saveToOE(format);

    for (let i = 0; i < allLayers.length; i++) {
      const layer = allLayers[i];
      layer.visible = layerStates[i];
    }
  }

  function hasActiveDocument() {
    app.echoToOE(app.documents.length > 0 ? "true" : "false");
  }
  // End of photopea functions

  const MESSAGE_END_ACK = "done";
  const MESSAGE_ERROR = "error";
  const PHOTOPEA_URL = "https://www.photopea.com/";
  class PhotopeaContext {
    constructor(photopeaIframe) {
      this.photopeaIframe = photopeaIframe;
      this.timeout = 1000;
    }

    navigateIframe() {
      const iframe = this.photopeaIframe;
      const editorURL = PHOTOPEA_URL;

      return new Promise(async (resolve) => {
        if (iframe.src !== editorURL) {
          iframe.src = editorURL;
          // Stop waiting after 10s.
          setTimeout(resolve, 10000);

          // Testing whether photopea is able to accept message.
          while (true) {
            try {
              await this.invoke(hasActiveDocument);
              break;
            } catch (e) {
              console.log("Keep waiting for photopea to accept message.");
            }
          }
          this.timeout = 5000; // Restore to a longer timeout in normal messaging.
        }
        resolve();
      });
    }

    // From https://github.com/huchenlei/stable-diffusion-ps-pea/blob/main/src/Photopea.ts
    postMessageToPhotopea(message) {
      return new Promise((resolve, reject) => {
        const responseDataPieces = [];
        let hasError = false;
        const photopeaMessageHandle = (event) => {
          if (event.source !== this.photopeaIframe.contentWindow) {
            return;
          }
          // Filter out the ping messages
          if (typeof event.data === 'string' && event.data.includes('MSFAPI#')) {
            return;
          }
          // Ignore "done" when no data has been received. The "done" can come from
          // MSFAPI ping.
          if (event.data === MESSAGE_END_ACK && responseDataPieces.length === 0) {
            return;
          }
          if (event.data === MESSAGE_END_ACK) {
            window.removeEventListener("message", photopeaMessageHandle);
            if (hasError) {
              reject('Photopea Error.');
            } else {
              resolve(responseDataPieces.length === 1 ? responseDataPieces[0] : responseDataPieces);
            }
          } else if (event.data === MESSAGE_ERROR) {
            responseDataPieces.push(event.data);
            hasError = true;
          } else {
            responseDataPieces.push(event.data);
          }
        };

        window.addEventListener("message", photopeaMessageHandle);
        setTimeout(() => reject("Photopea message timeout"), this.timeout);
        this.photopeaIframe.contentWindow.postMessage(message, "*");
      });
    }

    // From https://github.com/huchenlei/stable-diffusion-ps-pea/blob/main/src/Photopea.ts
    async invoke(func, ...args) {
      await this.navigateIframe();
      const message = `${func.toString()} ${func.name}(${args.map(arg => JSON.stringify(arg)).join(',')});`;
      try {
        return await this.postMessageToPhotopea(message);
      } catch (e) {
        throw `Failed to invoke ${func.name}. ${e}.`;
      }
    }

    /**
     * Fetch detected maps from each ControlNet units.
     * Create a new photopea document.
     * Add those detected maps to the created document.
     */
    async fetchFromControlNet(tabs) {
      if (tabs.length === 0) return;
      const isImg2Img = tabs[0].querySelector('.cnet-mask-upload').id.includes('img2img');
      const generationType = isImg2Img ? 'img2img' : 'txt2img';
      const width = gradioApp().querySelector(`#${generationType}_width input[type=number]`).value;
      const height = gradioApp().querySelector(`#${generationType}_height input[type=number]`).value;

      const layerNames = ["background"];
      await this.invoke(pasteImage, createBlackImageBase64(width, height));
      await new Promise(r => setTimeout(r, 200));
      for (const [i, tab] of tabs.entries()) {
        const generatedImage = tab.querySelector('.cnet-generated-image-group .cnet-image img');
        if (!generatedImage) continue;
        await this.invoke(pasteImage, generatedImage.src);
        // Wait 200ms for pasting to fully complete so that we do not ended up with 2 separate
        // documents.
        await new Promise(r => setTimeout(r, 200));
        layerNames.push(`unit-${i}`);
      }
      await this.invoke(removeLayersWithNames, layerNames);
      await this.invoke(setLayerNames, layerNames.reverse());
    }

    /**
     * Send the images in the active photopea document back to each ControlNet units.
     */
    async sendToControlNet(tabs) {
      // Gradio's image widgets are inputs. To set the image in one, we set the image on the input and
      // force it to refresh.
      function setImageOnInput(imageInput, file) {
        // Createa a data transfer element to set as the data in the input.
        const dt = new DataTransfer();
        dt.items.add(file);
        const list = dt.files;

        // Actually set the image in the image widget.
        imageInput.files = list;

        // Foce the image widget to update with the new image, after setting its source files.
        const event = new Event('change', {
          'bubbles': true,
          "composed": true
        });
        imageInput.dispatchEvent(event);
      }

      function sendToControlNetUnit(b64Image, index) {
        const tab = tabs[index];
        // Upload image to output image element.
        const outputImage = tab.querySelector('.cnet-photopea-output');
        const outputImageUpload = outputImage.querySelector('input[type="file"]');
        setImageOnInput(outputImageUpload, new File([b64toBlob(b64Image, "image/png")], "photopea_output.png"));

        // Make sure `UsePreviewAsInput` checkbox is checked.
        const checkbox = tab.querySelector('.cnet-preview-as-input input[type="checkbox"]');
        if (!checkbox.checked) {
          checkbox.click();
        }
      }

      const layerNames =
        JSON.parse(await this.invoke(getAllLayerNames))
          .filter(name => /unit-\d+/.test(name));

      for (const layerName of layerNames) {
        const arrayBuffer = await this.invoke(exportSelectedLayerOnly, 'PNG', layerName);
        const b64Image = base64ArrayBuffer(arrayBuffer);
        const layerIndex = Number.parseInt(layerName.split('-')[1]);
        sendToControlNetUnit(b64Image, layerIndex);
      }
    }
  }

  let photopeaWarningShown = false;

  function firstTimeUserPrompt() {
    if (opts.controlnet_photopea_warning){
      const photopeaPopupMsg = "you are about to connect to https://photopea.com\n" +
        "- Click OK: proceed.\n" +
        "- Click Cancel: abort.\n" +
        "Photopea integration can be disabled in Settings > ControlNet > Disable photopea edit.\n" +
        "This popup can be disabled in Settings > ControlNet > Photopea popup warning.";
      if (photopeaWarningShown || confirm(photopeaPopupMsg)) photopeaWarningShown = true;
      else return false;
    }
    return true;
  }

  const cnetRegisteredAccordions = new Set();
  function loadPhotopea() {
    function registerCallbacks(accordion) {
      const photopeaMainTrigger = accordion.querySelector('.cnet-photopea-main-trigger');
      // Photopea edit feature disabled.
      if (!photopeaMainTrigger) {
        console.log("ControlNet photopea edit disabled.");
        return;
      }

      const closeModalButton = accordion.querySelector('.cnet-photopea-edit .cnet-modal-close');
      const tabs = accordion.querySelectorAll('.controlnet .input-accordion');
      const photopeaIframe = accordion.querySelector('.photopea-iframe');
      const photopeaContext = new PhotopeaContext(photopeaIframe, tabs);

      tabs.forEach(tab => {
        const photopeaChildTrigger = tab.querySelector('.cnet-photopea-child-trigger');
        photopeaChildTrigger.addEventListener('click', async () => {
          if (!firstTimeUserPrompt()) return;

          photopeaMainTrigger.click();
          if (await photopeaContext.invoke(hasActiveDocument) === "false") {
            await photopeaContext.fetchFromControlNet(tabs);
          }
        });
      });
      accordion.querySelector('.photopea-fetch').addEventListener('click', () => photopeaContext.fetchFromControlNet(tabs));
      accordion.querySelector('.photopea-send').addEventListener('click', () => {
        photopeaContext.sendToControlNet(tabs)
        closeModalButton.click();
      });
    }

    const accordions = gradioApp().querySelectorAll('#controlnet');
    accordions.forEach(accordion => {
      if (cnetRegisteredAccordions.has(accordion)) return;
      registerCallbacks(accordion);
      cnetRegisteredAccordions.add(accordion);
    });
  }

  onUiUpdate(loadPhotopea);
})();