import * as React from "react";
import { useApolloClient } from "@apollo/client";
import {
  Button,
  Box,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  Select,
  useToast,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";

import { safeImageUrl } from "../../util";
import useSupport from "./useSupport";

/**
 * AppearanceLayerSupportUploadModal helps Support users create and upload PNGs for
 * broken appearance layers. Useful when the auto-converters are struggling,
 * e.g. the SWF uses a color filter our server-side Flash player can't support!
 */
function AppearanceLayerSupportUploadModal({ item, layer, isOpen, onClose }) {
  const [step, setStep] = React.useState(1);
  const [imageOnBlackUrl, setImageOnBlackUrl] = React.useState(null);
  const [imageOnWhiteUrl, setImageOnWhiteUrl] = React.useState(null);

  const [imageWithAlphaUrl, setImageWithAlphaUrl] = React.useState(null);
  const [imageWithAlphaBlob, setImageWithAlphaBlob] = React.useState(null);
  const [numWarnings, setNumWarnings] = React.useState(null);

  const [isUploading, setIsUploading] = React.useState(false);
  const [uploadError, setUploadError] = React.useState(null);

  const [conflictMode, setConflictMode] = React.useState("onBlack");

  const { supportSecret } = useSupport();
  const toast = useToast();
  const apolloClient = useApolloClient();

  // Once both images are ready, merge them!
  React.useEffect(() => {
    if (!imageOnBlackUrl || !imageOnWhiteUrl) {
      return;
    }

    setImageWithAlphaUrl(null);
    setNumWarnings(null);
    setIsUploading(false);

    mergeIntoImageWithAlpha(
      imageOnBlackUrl,
      imageOnWhiteUrl,
      conflictMode,
    ).then(([url, blob, numWarnings]) => {
      setImageWithAlphaUrl(url);
      setImageWithAlphaBlob(blob);
      setNumWarnings(numWarnings);
    });
  }, [imageOnBlackUrl, imageOnWhiteUrl, conflictMode]);

  const onUpload = React.useCallback(
    (e) => {
      const file = e.target.files[0];
      if (!file) {
        return;
      }

      const reader = new FileReader();
      reader.onload = (re) => {
        switch (step) {
          case 1:
            setImageOnBlackUrl(re.target.result);
            setStep(2);
            return;
          case 2:
            setImageOnWhiteUrl(re.target.result);
            setStep(3);
            return;
          default:
            throw new Error(`unexpected step ${step}`);
        }
      };
      reader.readAsDataURL(file);
    },
    [step],
  );

  const onSubmitFinalImage = React.useCallback(async () => {
    setIsUploading(true);
    setUploadError(null);
    try {
      const res = await fetch(`/api/uploadLayerImage?layerId=${layer.id}`, {
        method: "POST",
        headers: {
          "DTI-Support-Secret": supportSecret,
        },
        body: imageWithAlphaBlob,
      });

      if (!res.ok) {
        setIsUploading(false);
        setUploadError(
          new Error(`Network error: ${res.status} ${res.statusText}`),
        );
        return;
      }

      setIsUploading(false);
      onClose();
      toast({
        status: "success",
        title: "Image successfully uploaded",
        description: "It might take a few seconds to update in the app!",
      });

      // NOTE: I tried to do this as a cache update, but I couldn't ever get
      //       the fragment with size parameters to work :/ (Other fields would
      //       update, but not these!) Ultimately the eviction is the only
      //       reliable method I found :/
      apolloClient.cache.evict({
        id: `AppearanceLayer:${layer.id}`,
        fieldName: "imageUrl",
      });
      apolloClient.cache.evict({
        id: `AppearanceLayer:${layer.id}`,
        fieldName: "imageUrlV2",
      });
    } catch (e) {
      setIsUploading(false);
      setUploadError(e);
    }
  }, [
    imageWithAlphaBlob,
    supportSecret,
    layer.id,
    toast,
    onClose,
    apolloClient.cache,
  ]);

  return (
    <Modal
      // HACK: The built-in `full` size also sets 100% height, which I don't
      //       want; and the docs suggest it will accept px values, but it
      //       doesn't. But I discovered that invalid size values are treated
      //       as 100% width and auto height, so, okay! ^_^` Probably a bug,
      //       but I intend to use it for now!
      size="full-hack"
      isOpen={isOpen}
      onClose={onClose}
    >
      <ModalOverlay>
        <ModalContent>
          <ModalHeader textAlign="center">
            Upload PNG for {item.name}
          </ModalHeader>
          <ModalCloseButton />
          <ModalBody
            paddingBottom="2"
            display="flex"
            flexDirection="column"
            alignItems="center"
            textAlign="center"
          >
            {(step === 1 || step === 2) && (
              <AppearanceLayerSupportScreenshotStep
                layer={layer}
                step={step}
                onUpload={onUpload}
              />
            )}
            {step === 3 && (
              <AppearanceLayerSupportReviewStep
                imageWithAlphaUrl={imageWithAlphaUrl}
                numWarnings={numWarnings}
                conflictMode={conflictMode}
                onChangeConflictMode={setConflictMode}
              />
            )}
          </ModalBody>
          <ModalFooter>
            <Button colorScheme="red" onClick={() => setStep(1)}>
              Restart
            </Button>
            <Box flex="1 1 0" />
            {uploadError && (
              <Box
                color="red.400"
                fontSize="sm"
                marginRight="2"
                textAlign="right"
              >
                {uploadError.message}
              </Box>
            )}
            <Button onClick={onClose}>Close</Button>
            {step === 3 && (
              <Button
                colorScheme="green"
                marginLeft="2"
                onClick={onSubmitFinalImage}
                isLoading={isUploading}
              >
                Upload
              </Button>
            )}
          </ModalFooter>
        </ModalContent>
      </ModalOverlay>
    </Modal>
  );
}

function AppearanceLayerSupportScreenshotStep({ layer, step, onUpload }) {
  return (
    <>
      <Box>
        <b>Step {step}:</b> Take a screenshot of exactly the 600&times;600 Flash
        region, then upload it below.
        <br />
        The border will turn green once the entire region is in view.
      </Box>
      <Box
        display="flex"
        alignItems="center"
        maxWidth="600px"
        width="100%"
        marginTop="2"
      >
        <input key={step} type="file" accept="image/png" onChange={onUpload} />
        <Box flex="1 1 0" />
        <Button
          as="a"
          href="https://support.mozilla.org/en-US/kb/firefox-screenshots"
          target="_blank"
          size="xs"
          marginLeft="1"
          colorScheme="gray"
        >
          Firefox help <ExternalLinkIcon marginLeft="1" />
        </Button>
        <Button
          as="a"
          href="https://umaar.com/dev-tips/156-element-screenshot/"
          target="_blank"
          size="xs"
          marginLeft="1"
          colorScheme="gray"
        >
          Chrome help <ExternalLinkIcon marginLeft="1" />
        </Button>
      </Box>
      <AppearanceLayerSupportFlashPlayer
        swfUrl={layer.swfUrl}
        backgroundColor={step === 1 ? "black" : "white"}
      />
    </>
  );
}

function AppearanceLayerSupportReviewStep({
  imageWithAlphaUrl,
  numWarnings,
  conflictMode,
  onChangeConflictMode,
}) {
  if (imageWithAlphaUrl == null) {
    return <Box>Generating image…</Box>;
  }

  const ratioBad = numWarnings / (600 * 600);
  const ratioGood = 1 - ratioBad;

  return (
    <>
      <Box>
        <b>Step 3:</b> Does this look correct? If so, let's upload it!
      </Box>
      <Box fontSize="sm" color="gray.500">
        ({Math.floor(ratioGood * 10000) / 100}% match,{" "}
        {Math.floor(ratioBad * 10000) / 100}% mismatch.)
      </Box>
      <Box
        // Checkerboard pattern: https://stackoverflow.com/a/35362074/107415
        backgroundImage="linear-gradient(45deg, #c0c0c0 25%, transparent 25%), linear-gradient(-45deg, #c0c0c0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c0c0c0 75%), linear-gradient(-45deg, transparent 75%, #c0c0c0 75%)"
        backgroundSize="20px 20px"
        backgroundPosition="0 0, 0 10px, 10px -10px, -10px 0px"
        marginTop="2"
      >
        {imageWithAlphaUrl && (
          <img
            src={imageWithAlphaUrl}
            width={600}
            height={600}
            alt="Generated layer PNG, on a checkered background"
          />
        )}
      </Box>
      <Box>
        {numWarnings > 0 && (
          <Box
            display="flex"
            flexDirection="row"
            alignItems="center"
            justifyContent="center"
            width="600px"
            marginTop="2"
          >
            <Box flex="0 1 auto" marginRight="2">
              When pixels conflict, we use…
            </Box>
            <Select
              flex="0 0 200px"
              value={conflictMode}
              onChange={(e) => onChangeConflictMode(e.target.value)}
            >
              <option value="onBlack">the version on black</option>
              <option value="onWhite">the version on white</option>
              <option value="transparent">transparent pixels</option>
              <option value="moreColorful">the more colorful pixels</option>
            </Select>
          </Box>
        )}
      </Box>
    </>
  );
}

function AppearanceLayerSupportFlashPlayer({ swfUrl, backgroundColor }) {
  const [isVisible, setIsVisible] = React.useState(null);
  const regionRef = React.useRef(null);

  // We detect whether the entire SWF region is visible, because Flash only
  // bothers to render in visible places. So, screenshotting a SWF container
  // that isn't fully visible will fill the not-visible space with black,
  // instead of the actual SWF content. We change the border color to hint this
  // to the user!
  React.useLayoutEffect(() => {
    const region = regionRef.current;
    if (!region) {
      return;
    }

    const scrollParent = region.closest(".chakra-modal__overlay");
    if (!scrollParent) {
      throw new Error(`could not find .chakra-modal__overlay scroll parent`);
    }

    const onMountOrScrollOrResize = () => {
      const regionBox = region.getBoundingClientRect();
      const scrollParentBox = scrollParent.getBoundingClientRect();
      const isVisible =
        regionBox.left > scrollParentBox.left &&
        regionBox.right < scrollParentBox.right &&
        regionBox.top > scrollParentBox.top &&
        regionBox.bottom < scrollParentBox.bottom;
      setIsVisible(isVisible);
    };

    onMountOrScrollOrResize();

    scrollParent.addEventListener("scroll", onMountOrScrollOrResize);
    window.addEventListener("resize", onMountOrScrollOrResize);

    return () => {
      scrollParent.removeEventListener("scroll", onMountOrScrollOrResize);
      window.removeEventListener("resize", onMountOrScrollOrResize);
    };
  }, []);

  let borderColor;
  if (isVisible === null) {
    borderColor = "gray.400";
  } else if (isVisible === false) {
    borderColor = "red.400";
  } else if (isVisible === true) {
    borderColor = "green.400";
  }

  return (
    <Box
      data-hint="No: Don't screenshot this node! Use the one below!"
      borderWidth="3px"
      borderStyle="dashed"
      borderColor={borderColor}
      marginTop="4"
      padding="1px"
      backgroundColor={backgroundColor}
    >
      <Box
        // In Chrome on macOS, I observe that I need to shift the SWF
        // one pixel to the left in order to capture it correctly.
        //
        // So, in Chrome, who are using a DevTools procedure, we add a
        // hint that this is the node to use.
        //
        // In Firefox, the GUI to target the SWF seems to work just
        // fine. So, the margin hack and these hints don't matter!
        data-hint="Yes: Screenshot this node! This is the one!"
        backgroundColor={backgroundColor}
      >
        <Box
          data-hint="No: Don't screenshot this node! Use the one above!"
          width="600px"
          height="600px"
          // In Chrome on macOS, I observe that I need to shift the SWF
          // one pixel to the left in order to capture it correctly.
          //
          // But this disrupts the Firefox capture! So here, we do a cheap
          // browser detection, to shift left only in Chrome.
          marginLeft={navigator.userAgent.includes("Chrome") ? "-1px" : "0"}
          ref={regionRef}
        >
          <object
            type="application/x-shockwave-flash"
            data={safeImageUrl(swfUrl)}
            width="100%"
            height="100%"
          >
            <param name="wmode" value="transparent" />
          </object>
        </Box>
      </Box>
    </Box>
  );
}

async function mergeIntoImageWithAlpha(
  imageOnBlackUrl,
  imageOnWhiteUrl,
  conflictMode,
) {
  const [imageOnBlack, imageOnWhite] = await Promise.all([
    readImageDataFromUrl(imageOnBlackUrl),
    readImageDataFromUrl(imageOnWhiteUrl),
  ]);

  const [imageWithAlphaData, numWarnings] = mergeDataIntoImageWithAlpha(
    imageOnBlack,
    imageOnWhite,
    conflictMode,
  );
  const [imageWithAlphaUrl, imageWithAlphaBlob] =
    await writeImageDataToUrlAndBlob(imageWithAlphaData);

  return [imageWithAlphaUrl, imageWithAlphaBlob, numWarnings];
}

function mergeDataIntoImageWithAlpha(imageOnBlack, imageOnWhite, conflictMode) {
  const imageWithAlpha = new ImageData(600, 600);
  let numWarnings = 0;

  for (let x = 0; x < 600; x++) {
    for (let y = 0; y < 600; y++) {
      const pixelIndex = (600 * y + x) << 2;

      const rOnBlack = imageOnBlack.data[pixelIndex];
      const gOnBlack = imageOnBlack.data[pixelIndex + 1];
      const bOnBlack = imageOnBlack.data[pixelIndex + 2];
      const rOnWhite = imageOnWhite.data[pixelIndex];
      const gOnWhite = imageOnWhite.data[pixelIndex + 1];
      const bOnWhite = imageOnWhite.data[pixelIndex + 2];
      if (rOnWhite < rOnBlack || gOnWhite < gOnBlack || bOnWhite < bOnBlack) {
        if (numWarnings < 100) {
          console.warn(
            `[${x}x${y}] color on white should be lighter than color on ` +
              `black, see pixel ${x}x${y}: ` +
              `#${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
              `${gOnWhite.toString(16)}` +
              ` vs ` +
              `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
              `${gOnWhite.toString(16)}. ` +
              `Using conflict mode ${conflictMode} to fall back.`,
          );
        }

        const [r, g, b, a] = resolveConflict(
          [rOnBlack, gOnBlack, bOnBlack],
          [rOnWhite, gOnWhite, bOnWhite],
          conflictMode,
        );
        imageWithAlpha.data[pixelIndex] = r;
        imageWithAlpha.data[pixelIndex + 1] = g;
        imageWithAlpha.data[pixelIndex + 2] = b;
        imageWithAlpha.data[pixelIndex + 3] = a;

        numWarnings++;
        continue;
      }

      // The true alpha is how close together the on-white and on-black colors
      // are. If they're totally the same, it's 255 opacity. If they're totally
      // different, it's 0 opacity. In between, it scales linearly with the
      // difference!
      const alpha = 255 - (rOnWhite - rOnBlack);

      // Check that the alpha derived from other channels makes sense too.
      const alphaByB = 255 - (bOnWhite - bOnBlack);
      const alphaByG = 255 - (gOnWhite - gOnBlack);
      const highestAlpha = Math.max(Math.max(alpha, alphaByB), alphaByG);
      const lowestAlpha = Math.min(Math.min(alpha, alphaByB, alphaByG));
      if (highestAlpha - lowestAlpha > 2) {
        if (numWarnings < 100) {
          console.warn(
            `[${x}x${y}] derived alpha values don't match: ` +
              `${alpha} vs ${alphaByB} vs ${alphaByG}. ` +
              `Colors: #${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
              `${gOnWhite.toString(16)}` +
              ` vs ` +
              `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
              `${gOnWhite.toString(16)}. ` +
              `Using conflict mode ${conflictMode} to fall back.`,
          );
        }

        const [r, g, b, a] = resolveConflict(
          [rOnBlack, gOnBlack, bOnBlack],
          [rOnWhite, gOnWhite, bOnWhite],
          conflictMode,
        );
        imageWithAlpha.data[pixelIndex] = r;
        imageWithAlpha.data[pixelIndex + 1] = g;
        imageWithAlpha.data[pixelIndex + 2] = b;
        imageWithAlpha.data[pixelIndex + 3] = a;

        numWarnings++;
        continue;
      }

      // And the true color is the color on black, divided by the true alpha.
      // We can derive this from the definition of the color on black, which is
      // simply the true color times the true alpha. Divide to undo!
      const alphaRatio = alpha / 255;
      const rOnAlpha = Math.round(rOnBlack / alphaRatio);
      const gOnAlpha = Math.round(gOnBlack / alphaRatio);
      const bOnAlpha = Math.round(bOnBlack / alphaRatio);

      imageWithAlpha.data[pixelIndex] = rOnAlpha;
      imageWithAlpha.data[pixelIndex + 1] = gOnAlpha;
      imageWithAlpha.data[pixelIndex + 2] = bOnAlpha;
      imageWithAlpha.data[pixelIndex + 3] = alpha;
    }
  }

  return [imageWithAlpha, numWarnings];
}

/**
 * readImageDataFromUrl reads an image URL to ImageData, by drawing it on a
 * canvas and reading ImageData back from it.
 */
async function readImageDataFromUrl(url) {
  const image = new Image();

  await new Promise((resolve, reject) => {
    image.onload = resolve;
    image.onerror = reject;
    image.src = url;
  });

  const canvas = document.createElement("canvas");
  canvas.width = 600;
  canvas.height = 600;

  const ctx = canvas.getContext("2d");
  ctx.drawImage(image, 0, 0, 600, 600);
  return ctx.getImageData(0, 0, 600, 600);
}

/**
 * writeImageDataToUrl writes an ImageData to a data URL and Blob, by drawing
 * it on a canvas and reading the URL and Blob back from it.
 */
async function writeImageDataToUrlAndBlob(imageData) {
  const canvas = document.createElement("canvas");
  canvas.width = 600;
  canvas.height = 600;

  const ctx = canvas.getContext("2d");
  ctx.putImageData(imageData, 0, 0);

  const dataUrl = canvas.toDataURL("image/png");
  const blob = await new Promise((resolve) =>
    canvas.toBlob(resolve, "image/png"),
  );
  return [dataUrl, blob];
}

function resolveConflict(
  [rOnBlack, gOnBlack, bOnBlack],
  [rOnWhite, gOnWhite, bOnWhite],
  conflictMode,
) {
  if (conflictMode === "onBlack") {
    return [rOnBlack, gOnBlack, bOnBlack, 255];
  } else if (conflictMode === "onWhite") {
    return [rOnWhite, gOnWhite, bOnWhite, 255];
  } else if (conflictMode === "transparent") {
    return [0, 0, 0, 0];
  } else if (conflictMode === "moreColorful") {
    const sOnBlack = computeSaturation(rOnBlack, gOnBlack, bOnBlack);
    const sOnWhite = computeSaturation(rOnWhite, gOnWhite, bOnWhite);
    if (sOnBlack > sOnWhite) {
      return [rOnBlack, gOnBlack, bOnBlack, 255];
    } else {
      return [rOnWhite, gOnWhite, bOnWhite, 255];
    }
  } else {
    throw new Error(`unexpected conflict mode ${conflictMode}`);
  }
}

/**
 * Returns the given color's saturation, as a ratio from 0 to 1.
 * Adapted from https://css-tricks.com/converting-color-spaces-in-javascript/
 */
function computeSaturation(r, g, b) {
  r /= 255;
  g /= 255;
  b /= 255;

  const cmin = Math.min(r, g, b);
  const cmax = Math.max(r, g, b);
  const delta = cmax - cmin;

  const l = (cmax + cmin) / 2;
  const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));

  return s;
}

export default AppearanceLayerSupportUploadModal;