from io import BytesIO

try:
    from pillow_heif import HeifImagePlugin  # noqa: F401
except ImportError:
    pass

from willow.image import (
    AvifImageFile,
    BadImageOperationError,
    BMPImageFile,
    GIFImageFile,
    HeicImageFile,
    IcoImageFile,
    Image,
    JPEGImageFile,
    PNGImageFile,
    RGBAImageBuffer,
    RGBImageBuffer,
    TIFFImageFile,
    WebPImageFile,
)


class UnsupportedRotation(Exception):
    pass


def _PIL_Image():
    import PIL.Image

    return PIL.Image


def _PIL_ImageCms():
    import PIL.ImageCms

    return PIL.ImageCms


class PillowImage(Image):
    def __init__(self, image):
        self.image = image

    @classmethod
    def check(cls):
        _PIL_Image()

    @classmethod
    def is_format_supported(cls, image_format):
        formats = _PIL_Image().registered_extensions()
        return image_format in formats.values()

    @Image.operation
    def get_size(self):
        return self.image.size

    @Image.operation
    def get_frame_count(self):
        # Animation is not supported by PIL
        return 1

    @Image.operation
    def has_alpha(self):
        img = self.image
        return img.mode in ("RGBA", "LA") or (
            img.mode == "P" and "transparency" in img.info
        )

    @Image.operation
    def has_animation(self):
        # Animation is not supported by PIL
        return False

    @Image.operation
    def resize(self, size):
        # Convert 1 and P images to RGB to improve resize quality
        # (palleted images don't get antialiased or filtered when minified)
        if self.image.mode in ["1", "P"]:
            if self.has_alpha():
                image = self.image.convert("RGBA")
            else:
                image = self.image.convert("RGB")
        else:
            image = self.image

        # LANCZOS was previously known as ANTIALIAS
        return PillowImage(image.resize(size, _PIL_Image().Resampling.LANCZOS))

    @Image.operation
    def crop(self, rect):
        left, top, right, bottom = rect
        width, height = self.image.size
        if (
            left >= right
            or left >= width
            or right <= 0
            or top >= bottom
            or top >= height
            or bottom <= 0
        ):
            raise BadImageOperationError(f"Invalid crop dimensions: {rect!r}")

        # clamp to image boundaries
        clamped_rect = (
            max(0, left),
            max(0, top),
            min(right, width),
            min(bottom, height),
        )

        return PillowImage(self.image.crop(clamped_rect))

    @Image.operation
    def rotate(self, angle):
        """
        Accept a multiple of 90 to pass to the underlying Pillow function
        to rotate the image.
        """

        Image = _PIL_Image()
        ORIENTATION_TO_TRANSPOSE = {
            90: Image.Transpose.ROTATE_90,
            180: Image.Transpose.ROTATE_180,
            270: Image.Transpose.ROTATE_270,
        }

        modulo_angle = angle % 360

        # is we're rotating a multiple of 360, it's the same as a no-op
        if not modulo_angle:
            return self

        transpose_code = ORIENTATION_TO_TRANSPOSE.get(modulo_angle)

        if not transpose_code:
            raise UnsupportedRotation(
                "Sorry - we only support right angle rotations - i.e. multiples of 90 degrees"
            )

        # We call "transpose", as it rotates the image,
        # updating the height and width, whereas using 'rotate'
        # only changes the contents of the image.
        rotated = self.image.transpose(transpose_code)

        return PillowImage(rotated)

    @Image.operation
    def set_background_color_rgb(self, color):
        if not self.has_alpha():
            # Don't change image that doesn't have an alpha channel
            return self

        # Check type of color
        if not isinstance(color, (tuple, list)) or not len(color) == 3:
            raise TypeError("the 'color' argument must be a 3-element tuple or list")

        # Convert non-RGB colour formats to RGB
        # As we only allow the background color to be passed in as RGB, we
        # convert the format of the original image to match.
        image = self.image.convert("RGBA")

        # Generate a new image with background colour and draw existing image on top of it
        # The new image must temporarily be RGBA in order for alpha_composite to work
        new_image = _PIL_Image().new(
            "RGBA", self.image.size, (color[0], color[1], color[2], 255)
        )

        if hasattr(new_image, "alpha_composite"):
            new_image.alpha_composite(image)
        else:
            # Pillow < 4.2.0 fallback
            # This method may be slower as the operation generates a new image
            new_image = _PIL_Image().alpha_composite(new_image, image)

        return PillowImage(new_image.convert("RGB"))

    def get_icc_profile(self):
        return self.image.info.get("icc_profile")

    def get_exif_data(self):
        return self.image.info.get("exif")

    @Image.operation
    def transform_colorspace_to_srgb(self, rendering_intent=0):
        """
        Transforms the color of the image to fit inside sRGB color gamut using the
        embedded ICC profile. The resulting image will always be in RGB(A) mode
        and will have a small generic sRGB ICC profile embedded.

        If the image does not have an ICC profile this operation is a no-op.
        Images without a profile are commonly assumed to be in sRGB color space
        already.

        :param rendering_intent: Controls how out-of-gamut colors and handled.
        Defaults to 0 (perceptual) because this is what Pillow defaults to.
        :return: PillowImage in RGB mode
        :raises: PIL.ImageCms.PyCMSError

        Further reading:
            * https://pillow.readthedocs.io/en/stable/reference/ImageCms.html#PIL.ImageCms.profileToProfile
            * https://www.permajet.com/blog/rendering-intents-explained/
        """
        icc_profile = self.get_icc_profile()

        # Can't transform if there is no profile, no-op
        if icc_profile is None:
            return self

        ImageCms = _PIL_ImageCms()
        # ImageCmsProfile expects profile data to be file-like, give it BytesIO that quacks like a file 🦆
        icc_profile = ImageCms.ImageCmsProfile(BytesIO(icc_profile))

        # Output mode should always be RGB, unless the image has an alpha channel.
        output_mode = "RGBA" if self.has_alpha() else "RGB"

        # Attempt to convert from the embedded profile of the image to a generic sRGB one
        image = ImageCms.profileToProfile(
            self.image,
            icc_profile,
            ImageCms.createProfile("sRGB"),
            renderingIntent=rendering_intent,
            outputMode=output_mode,
        )
        return PillowImage(image)

    @Image.operation
    def save_as_jpeg(
        self,
        f,
        quality: int = 85,
        optimize: bool = False,
        progressive: bool = False,
        apply_optimizers: bool = True,
    ):
        """
        Save the image as a JPEG file.

        :param f: the file or file-like object to save to
        :param quality: the image quality
        :param optimize: Whether Pillow should optimize the file. When True, Pillow will
            attempt to compress the palette by eliminating unused colors.
        :param progressive: whether to save as progressive JPEG file.
        :param apply_optimizers: controls whether to run any configured optimizer libraries
        :return: JPEGImageFile
        """
        if self.image.mode in ["1", "P"]:
            image = self.image.convert("RGB")
        else:
            image = self.image

        kwargs = {"quality": quality}
        if optimize:
            kwargs["optimize"] = True
        if progressive:
            kwargs["progressive"] = True

        icc_profile = self.get_icc_profile()
        if icc_profile is not None:
            kwargs["icc_profile"] = icc_profile

        exif_data = self.get_exif_data()
        if exif_data is not None:
            kwargs["exif"] = exif_data

        image.save(f, "JPEG", **kwargs)
        if apply_optimizers:
            self.optimize(f, "jpeg")
        return JPEGImageFile(f)

    @Image.operation
    def save_as_png(self, f, optimize: bool = False, apply_optimizers: bool = True):
        """
        Save the image as a PNG file.

        :param f: the file or file-like object to save to
        :param optimize: Whether Pillow should optimize the file. When True, Pillow will
            attempt to compress the palette by eliminating unused colors.
        :param apply_optimizers: controls whether to run any configured optimizer libraries
        :return: PNGImageFile
        """

        kwargs = {}
        image = self.image
        icc_profile = self.get_icc_profile()
        if icc_profile is not None:
            # If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
            # about how we handle the color conversion to RGB. We don't want to retain
            # the color profile as-is because it is not meant for RGB images and
            # will result in inaccurate colors. The transformation to sRGB should result
            # in a more accurate representation of the original image, though
            # it will likely not be perfect.
            if self.image.mode == "CMYK":
                pillow_image = self.transform_colorspace_to_srgb()
                image = pillow_image.image
                kwargs["icc_profile"] = pillow_image.get_icc_profile()
            else:
                kwargs["icc_profile"] = icc_profile

        elif image.mode == "CMYK":
            image = image.convert("RGB")

        # Pillow 11.2 and older used to silently clip 32-bit I-mode images to 16
        # bits, which is the maximum PNG supports. This is silent behavior
        # deprecated in Pillow 11.3 but we explicitly want to retain it for
        # backwards compatibility.
        # See: https://pillow.readthedocs.io/en/stable/releasenotes/11.3.0.html#saving-i-mode-images-as-png
        if image.mode == "I":
            image = image.convert("I;16")

        # Pillow only checks presence of optimize kwarg, not its value
        if optimize:
            kwargs["optimize"] = True

        exif_data = self.get_exif_data()
        if exif_data is not None:
            kwargs["exif"] = exif_data

        image.save(f, "PNG", **kwargs)
        if apply_optimizers:
            self.optimize(f, "png")
        return PNGImageFile(f)

    @Image.operation
    def save_as_gif(self, f, apply_optimizers: bool = True):
        image = self.image

        # All gif files use either the L or P mode but we sometimes convert them
        # to RGB/RGBA to improve the quality of resizing. We must make sure that
        # they are converted back before saving.
        if image.mode not in ["L", "P"]:
            image = image.convert("P", palette=_PIL_Image().Palette.ADAPTIVE)

        kwargs = {}
        if "transparency" in image.info:
            kwargs["transparency"] = image.info["transparency"]

        image.save(f, "GIF", **kwargs)
        if apply_optimizers:
            self.optimize(f, "gif")
        return GIFImageFile(f)

    @Image.operation
    def save_as_webp(
        self,
        f,
        quality: int = 80,
        lossless: bool = False,
        apply_optimizers: bool = True,
    ):
        """
        Save the image as a WEBP file.

        :param f: the file or file-like object to save to
        :param quality: the image quality
        :param lossless: whether to save as lossless WEBP file.
        :param apply_optimizers: controls whether to run any configured optimizer libraries.
            Note that when lossless=True, this will be ignored.
        :return: WebPImageFile
        """

        kwargs = {"quality": quality, "lossless": lossless}

        image = self.image
        icc_profile = self.get_icc_profile()
        if icc_profile is not None:
            # If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
            # about how we handle the color space. WEBP will encode as RGB so we need to do extra
            # work to ensure the colors are as accurate as possible. We don't want to retain
            # the color profile as-is because it is not meant for RGB images and
            # will result in inaccurate colors. The transformation to sRGB should result
            # in a more accurate representation of the original image, though
            # it will likely not be perfect.
            if image.mode == "CMYK":
                pillow_image = self.transform_colorspace_to_srgb()
                image = pillow_image.image
                kwargs["icc_profile"] = pillow_image.get_icc_profile()
            else:
                kwargs["icc_profile"] = icc_profile

        image.save(f, "WEBP", **kwargs)
        if apply_optimizers and not lossless:
            self.optimize(f, "webp")
        return WebPImageFile(f)

    @Image.operation
    def save_as_heic(
        self,
        f,
        quality: int = 80,
        lossless: bool = False,
        apply_optimizers: bool = True,
    ):
        """
        Save the image as a HEIC file.

        :param f: the file or file-like object to save to
        :param quality: the image quality
        :param lossless: whether to save as lossless HEIC/HEIF file.
        :param apply_optimizers: controls whether to run any configured optimizer libraries.
            Note that when lossless=True, this will be ignored.
        :return: HeicImageFile
        """
        kwargs = {"quality": quality}
        if lossless:
            kwargs = {"quality": -1, "chroma": 444}

        image = self.image
        icc_profile = self.get_icc_profile()
        if icc_profile is not None:
            # If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
            # about how we handle the color space. HEIC will encode as RGB so we need to do extra
            # work to ensure the colors are as accurate as possible. We don't want to retain
            # the color profile as-is because it is not meant for RGB images and
            # will result in inaccurate colors. The transformation to sRGB should result
            # in a more accurate representation of the original image, though
            # it will likely not be perfect.
            if image.mode == "CMYK":
                pillow_image = self.transform_colorspace_to_srgb()
                image = pillow_image.image
                kwargs["icc_profile"] = pillow_image.get_icc_profile()
            else:
                kwargs["icc_profile"] = icc_profile

        image.save(f, "HEIF", **kwargs)

        if not lossless and apply_optimizers:
            self.optimize(f, "heic")

        return HeicImageFile(f)

    @Image.operation
    def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):
        kwargs = {"quality": quality}
        if lossless:
            kwargs = {
                # Quality of 100 implies lossless (according to libavif documentation)
                "quality": 100,
                # When encoding lossless images, don't use chroma subsampling (4:4:4 retains the most information)
                "subsampling": "4:4:4",
            }

        image = self.image
        icc_profile = self.get_icc_profile()
        if icc_profile is not None:
            # If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
            # about how we handle the color space. AVIF will encode as RGB so we need to do extra
            # work to ensure the colors are as accurate as possible. We don't want to retain
            # the color profile as-is because it is not meant for RGB images and
            # will result in inaccurate colors. The transformation to sRGB should result
            # in a more accurate representation of the original image, though
            # it will likely not be perfect.
            if image.mode == "CMYK":
                pillow_image = self.transform_colorspace_to_srgb()
                image = pillow_image.image
                kwargs["icc_profile"] = pillow_image.get_icc_profile()
            else:
                kwargs["icc_profile"] = icc_profile

        image.save(f, "AVIF", **kwargs)

        if not lossless and apply_optimizers:
            self.optimize(f, "heic")

        return AvifImageFile(f)

    @Image.operation
    def save_as_ico(self, f, apply_optimizers=True):
        self.image.save(f, "ICO")

        if apply_optimizers:
            self.optimize(f, "ico")

        return IcoImageFile(f)

    @Image.operation
    def auto_orient(self):
        # JPEG files can be orientated using an EXIF tag.
        # Make sure this orientation is applied to the data
        from PIL import ImageOps

        image = ImageOps.exif_transpose(self.image)

        return PillowImage(image)

    @Image.operation
    def get_pillow_image(self):
        return self.image

    @classmethod
    @Image.converter_from(JPEGImageFile)
    @Image.converter_from(PNGImageFile)
    @Image.converter_from(GIFImageFile, cost=200)
    @Image.converter_from(BMPImageFile)
    @Image.converter_from(TIFFImageFile)
    @Image.converter_from(WebPImageFile)
    @Image.converter_from(HeicImageFile)
    @Image.converter_from(AvifImageFile)
    @Image.converter_from(IcoImageFile)
    def open(cls, image_file):
        image_file.f.seek(0)
        image = _PIL_Image().open(image_file.f)
        image.load()

        return cls(image)

    @Image.converter_to(RGBImageBuffer)
    def to_buffer_rgb(self):
        image = self.image

        if image.mode != "RGB":
            image = image.convert("RGB")

        return RGBImageBuffer(image.size, image.tobytes())

    @Image.converter_to(RGBAImageBuffer)
    def to_buffer_rgba(self):
        image = self.image

        if image.mode != "RGBA":
            image = image.convert("RGBA")

        return RGBAImageBuffer(image.size, image.tobytes())


willow_image_classes = [PillowImage]
