import math
import os
import random
from abc import abstractmethod

import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
from lib.detection import FaceMetadata
from typing import List


class IllegalStateException(Exception):
    pass


class ImageProcessingContext(object):
    def __init__(self, img: Image.Image, img_data: np.array, faces: List[FaceMetadata]):
        self.img = img
        self.img_data = img_data
        self.faces = faces
        super().__init__()

    def filename(self):
        if hasattr(self.img, "filename"):
            return self.img.filename

        return "Unknown"


class ImageEffect(object):
    def __init__(self):
        super().__init__()

    @abstractmethod
    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        raise NotImplementedError


class GhostEffect(ImageEffect):
    def __init__(self, num_ghosts: int = 2, ghost_image_paths="./resources/ghosts/"):
        self.__ghost_images = []
        self._num_ghosts = num_ghosts

        max_ghost_width = 0
        for file in os.listdir(ghost_image_paths):
            full_path = os.path.join(ghost_image_paths, file)
            img = Image.open(full_path)
            max_ghost_width = max(max_ghost_width, img.width)
            self.__ghost_images.append(img)

        if len(self.__ghost_images) == 0:
            raise IllegalStateException(f"no images found in the path {ghost_image_paths}")

        self.__max_ghost_width = max_ghost_width

        super().__init__()

    def process_image(self, context: ImageProcessingContext):
        """
        for this, we need to:
            1. Create an all blank image the size of the passed in image
            2. Paste onto it all the ghost images we want
            3. Create a mask that is all blank, white where we want the image
            4. Composite ghost sheet onto OG image with mask
            5. ...
            6. profit?
        """
        img = context.img

        transparent_img = img.convert("RGBA")

        all_ghost_image = Image.new("RGBA", img.size, (255, 255, 255, 0))

        for (ghost_image, left, top) in self.__get_ghost_locations(img):
            right = left + ghost_image.width
            bottom = top + ghost_image.height

            # create the base ghost image
            all_ghost_image.paste(ghost_image, (left, top, right, bottom))

        # # Create mask that has the same setup
        ghost_mask = Image.new("L", img.size, 0)

        for x in range(0, ghost_mask.width):
            for y in range(0, ghost_mask.height):
                r, g, b, a = all_ghost_image.getpixel((x, y))

                # 'a' is the alpha value of the combinaton of all ghosts images at point x,y
                # An alpha of 0 means the pixel is transparent, which we use to create an image
                # mask only where the ghost pixels are located
                if a == 0:
                    continue

                ghost_mask.putpixel((x, y), 150)

        blur_mask = ghost_mask.filter(ImageFilter.BLUR)

        return Image.composite(all_ghost_image, transparent_img, blur_mask)

    def __get_ghost_locations(self, img: Image.Image) -> [(Image.Image, int, int)]:
        """
        Get all locations to put a ghost image

        For a given input image, determine how many ghost images to put on the
        image, and gives their top+left x,y coordinates

        Parameters:
        img (Image.Image): The image we want to add ghosts too

        Returns:
        [(Image.Image, int, int)]: list of ghosts including the (x,y)
                                   coordinates for the top+left location to
                                   place it on
                                   on the original image
        """

        # use the max ghost image width and the number of images to determine
        # how many ghosts to place
        num_ghosts_to_place = min(
            len(self.__ghost_images),
            self._num_ghosts,  # we dont want to overload the image with ghosts
            math.floor(img.width / self.__max_ghost_width),
        )

        result = []
        chosen_ghosts = set()
        for i in range(num_ghosts_to_place):
            while True:
                ghost_index = random.randint(0, len(self.__ghost_images) - 1)

                if str(ghost_index) not in chosen_ghosts:
                    chosen_ghosts.add(str(ghost_index))
                    break

            ghost = self.__ghost_images[ghost_index]

            # for the x location, we need to divide the main image width by
            # the number of ghosts to get where to put the ghost, and then
            # pick a random spot within that range
            # ex: two ghosts (x is final ghost location):
            # |           image           |
            # | ghost range | ghost range |
            # | x           |       x     |
            min_ghost_x = int((img.width / num_ghosts_to_place) * i)
            max_ghost_x = int(((img.width / num_ghosts_to_place) * (i + 1) - 1) - ghost.width)

            left = random.randint(min_ghost_x, max_ghost_x)
            top = random.randint(10, 30)
            result.append((ghost, left, top))

        return result


class FaceIdentifyEffect(ImageEffect):
    def __init__(self):
        super().__init__()

    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        img = context.img
        draw = ImageDraw.Draw(img)

        for face in context.faces:
            top, right, bottom, left = face.get_bounding_box()

            # using the bounds of the face, draw a red box around it!
            draw.rectangle([(left, top), (right, bottom)], None, (255, 0, 0), 1)

            mouth = face.get_mouth_points()
            draw.line(mouth, fill=(255, 0, 0, 64), width=1)

            left_eye, right_eye = face.get_eye_points()
            draw.line(left_eye, fill=(255, 0, 0, 64), width=1)
            draw.line(right_eye, fill=(255, 0, 0, 64), width=1)

        return img


class SaturationEffect(ImageEffect):
    def __init__(self, saturation_percentage):
        self.__saturation_percentage = saturation_percentage
        super().__init__()

    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        enhancer = ImageEnhance.Contrast(context.img)
        return enhancer.enhance(self.__saturation_percentage)


class TvStaticEffect(ImageEffect):
    """Gives a colour tv static like effect, something real spooky"""

    def __init__(self, sigma, static_tv_image_path="./resources/tv_static.jpg"):
        self.__sigma = sigma
        self._tv_static_image: Image = Image.open(static_tv_image_path)
        super().__init__()

    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        img = context.img
        static_img = Image.effect_noise(self._tv_static_image.size, self.__sigma)

        self._tv_static_image.putalpha(static_img)

        resized = self._tv_static_image.resize(img.size)

        # we also need to convert the original image to RGBA if it is not already
        rgba_img = img.convert("RGBA")

        return Image.blend(rgba_img, resized, 0.3)


class SwirlFaceEffect(ImageEffect):
    def __init__(self, swirl_strength=5):
        self.__swirl_strength = swirl_strength
        super().__init__()

    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        img = context.img

        for face in context.faces:
            top, right, bottom, left = face.get_bounding_box()

            # create a new image based on the current face
            face = Image.fromarray(context.img_data[top:bottom, left:right])

            # swirl the face
            processed_face = self.__swirl_rect(face, self.__swirl_strength)
            # add some alpha to the swirled image to make it less opaque
            processed_face.putalpha(100)
            # add a little bit of blur so that it is not so perfectly swirled
            processed_face = processed_face.filter(ImageFilter.GaussianBlur(2))

            # merge the face with the original image
            img.paste(processed_face, (left, top, right, bottom))

        return img

    def __swirl_rect(self, img: Image.Image, swirl_strength: int) -> Image.Image:
        # create a copy so that the orignal is not changed
        # not necessarily needed, but does avoid weird side effects
        to_swirl = img.copy()
        ellipse_mask = Image.new("L", to_swirl.size, 0)

        left = 0
        top = 0
        bottom = to_swirl.height - 1
        right = to_swirl.width - 1

        semimajor_axis = (bottom - top) / 2
        semiminor_axis = (right - left) / 2

        centerx = int(right / 2)
        centery = int(bottom / 2)

        for x in range(left, right):
            for y in range(top, bottom):
                # 1) convert to u,v space
                u = x - centerx
                v = y - centery

                # if w are at the center point of the swirl, do nothing
                if u == 0 and v == 0:
                    continue

                # 2) get the distance from pixel to the center and the angle
                # c = sqrt(u^2 + v^2)
                # thanks pythagoreous
                c = math.sqrt(math.pow(u, 2) + math.pow(v, 2))
                theta_radians = math.atan2(v, u)
                a = semiminor_axis  # horizontal axis
                b = semimajor_axis  # vertical axis
                # https://math.stackexchange.com/questions/432902/how-to-get-the-radius-of-an-ellipse-at-a-specific-angle-by-knowing-its-semi-majo
                ellipse_radius = (a * b) / math.sqrt(
                    (a * a) * (np.sin(theta_radians) * np.sin(theta_radians))
                    + (b * b) * (np.cos(theta_radians) * np.cos(theta_radians))
                )

                # 3) figure out if we should apply the swirl
                swirl_amount = 1 - (c / ellipse_radius)
                if swirl_amount <= 0:
                    continue

                # 3) find the angle to move the current pixel to. For pixels
                # closer to the centre, we want them to be more manipulated
                # (which is what the swirl amount is for), further pixels from
                # the centre should be not swirled as much
                twist_angle = ((random.randint(98, 102) / 100) * swirl_strength) * swirl_amount * math.pi * 2

                # 4) add the angle to twist to the current angle where the
                # pixel is located from centre
                theta_radians += twist_angle

                # 5) convert back to standard x,y coordinates
                new_x = math.cos(theta_radians) * c
                new_y = math.sin(theta_radians) * c

                # 6) update the current x,y pixel to have the RGB values of
                # the new calculated pixel based on the above math
                new_pixel = img.getpixel((new_x, new_y))
                to_swirl.putpixel((x, y), new_pixel)

        swirl_copy = to_swirl.copy()
        swirl_copy = swirl_copy.filter(ImageFilter.GaussianBlur(2))

        mask_blur = ellipse_mask.filter(ImageFilter.GaussianBlur(2))

        return Image.composite(to_swirl, swirl_copy, mask_blur)


class SketchyEyeEffect(ImageEffect):
    def __init__(self):
        super().__init__()

    def process_image(self, context: ImageProcessingContext):

        img = context.img
        draw = ImageDraw.Draw(img)

        for face in context.faces:

            for eye in face.get_eye_points():
                center_x, center_y, radius = self.__get_eye_dimensions(face.get_bounding_box(), eye)

                draw.ellipse(
                    [(center_x - radius, center_y - radius), (center_x + radius, center_y + radius)], (0, 0, 0, 100)
                )

        return img

    def __get_eye_dimensions(self, face_bounding_box: (int, int, int, int), eye_points: [(int, int)]) -> (int, int):
        face_top, face_right, face_bottom, face_left = face_bounding_box

        # find center of the each eye
        min_x = face_right
        max_x = face_left
        min_y = face_bottom
        max_y = face_top
        for (x, y) in eye_points:
            min_x = min(min_x, x)
            max_x = max(max_x, x)
            min_y = min(min_y, y)
            max_y = max(max_y, y)

        center_x = min_x + ((max_x - min_x) / 2)
        center_y = min_y + ((max_y - min_y) / 2)
        radius = max((max_x - min_x) / 2, (max_y - min_y) / 2)

        return center_x, center_y, radius

import math
import os
import random
from abc import abstractmethod

import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
from lib.detection import FaceMetadata
from typing import List


class IllegalStateException(Exception):
    pass


class ImageProcessingContext(object):
    def __init__(self, img: Image.Image, img_data: np.array, faces: List[FaceMetadata]):
        self.img = img
        self.img_data = img_data
        self.faces = faces
        super().__init__()

    def filename(self):
        if hasattr(self.img, "filename"):
            return self.img.filename

        return "Unknown"


class ImageEffect(object):
    def __init__(self):
        super().__init__()

    @abstractmethod
    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        raise NotImplementedError


class GhostEffect(ImageEffect):
    def __init__(self, num_ghosts: int = 2, ghost_image_paths="./resources/ghosts/"):
        self.__ghost_images = []
        self._num_ghosts = num_ghosts

        max_ghost_width = 0
        for file in os.listdir(ghost_image_paths):
            full_path = os.path.join(ghost_image_paths, file)
            img = Image.open(full_path)
            max_ghost_width = max(max_ghost_width, img.width)
            self.__ghost_images.append(img)

        if len(self.__ghost_images) == 0:
            raise IllegalStateException(f"no images found in the path {ghost_image_paths}")

        self.__max_ghost_width = max_ghost_width

        super().__init__()

    def process_image(self, context: ImageProcessingContext):
        """
        for this, we need to:
            1. Create an all blank image the size of the passed in image
            2. Paste onto it all the ghost images we want
            3. Create a mask that is all blank, white where we want the image
            4. Composite ghost sheet onto OG image with mask
            5. ...
            6. profit?
        """
        img = context.img

        transparent_img = img.convert("RGBA")

        all_ghost_image = Image.new("RGBA", img.size, (255, 255, 255, 0))

        for (ghost_image, left, top) in self.__get_ghost_locations(img):
            right = left + ghost_image.width
            bottom = top + ghost_image.height

            # create the base ghost image
            all_ghost_image.paste(ghost_image, (left, top, right, bottom))

        # # Create mask that has the same setup
        ghost_mask = Image.new("L", img.size, 0)

        for x in range(0, ghost_mask.width):
            for y in range(0, ghost_mask.height):
                r, g, b, a = all_ghost_image.getpixel((x, y))

                # 'a' is the alpha value of the combinaton of all ghosts images at point x,y
                # An alpha of 0 means the pixel is transparent, which we use to create an image
                # mask only where the ghost pixels are located
                if a == 0:
                    continue

                ghost_mask.putpixel((x, y), 150)

        blur_mask = ghost_mask.filter(ImageFilter.BLUR)

        return Image.composite(all_ghost_image, transparent_img, blur_mask)

    def __get_ghost_locations(self, img: Image.Image) -> [(Image.Image, int, int)]:
        """
        Get all locations to put a ghost image

        For a given input image, determine how many ghost images to put on the
        image, and gives their top+left x,y coordinates

        Parameters:
        img (Image.Image): The image we want to add ghosts too

        Returns:
        [(Image.Image, int, int)]: list of ghosts including the (x,y)
                                   coordinates for the top+left location to
                                   place it on
                                   on the original image
        """

        # use the max ghost image width and the number of images to determine
        # how many ghosts to place
        num_ghosts_to_place = min(
            len(self.__ghost_images),
            self._num_ghosts,  # we dont want to overload the image with ghosts
            math.floor(img.width / self.__max_ghost_width),
        )

        result = []
        chosen_ghosts = set()
        for i in range(num_ghosts_to_place):
            while True:
                ghost_index = random.randint(0, len(self.__ghost_images) - 1)

                if str(ghost_index) not in chosen_ghosts:
                    chosen_ghosts.add(str(ghost_index))
                    break

            ghost = self.__ghost_images[ghost_index]

            # for the x location, we need to divide the main image width by
            # the number of ghosts to get where to put the ghost, and then
            # pick a random spot within that range
            # ex: two ghosts (x is final ghost location):
            # |           image           |
            # | ghost range | ghost range |
            # | x           |       x     |
            min_ghost_x = int((img.width / num_ghosts_to_place) * i)
            max_ghost_x = int(((img.width / num_ghosts_to_place) * (i + 1) - 1) - ghost.width)

            left = random.randint(min_ghost_x, max_ghost_x)
            top = random.randint(10, 30)
            result.append((ghost, left, top))

        return result


class FaceIdentifyEffect(ImageEffect):
    def __init__(self):
        super().__init__()

    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        img = context.img
        draw = ImageDraw.Draw(img)

        for face in context.faces:
            top, right, bottom, left = face.get_bounding_box()

            # using the bounds of the face, draw a red box around it!
            draw.rectangle([(left, top), (right, bottom)], None, (255, 0, 0), 1)

            mouth = face.get_mouth_points()
            draw.line(mouth, fill=(255, 0, 0, 64), width=1)

            left_eye, right_eye = face.get_eye_points()
            draw.line(left_eye, fill=(255, 0, 0, 64), width=1)
            draw.line(right_eye, fill=(255, 0, 0, 64), width=1)

        return img


class SaturationEffect(ImageEffect):
    def __init__(self, saturation_percentage):
        self.__saturation_percentage = saturation_percentage
        super().__init__()

    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        enhancer = ImageEnhance.Contrast(context.img)
        return enhancer.enhance(self.__saturation_percentage)


class TvStaticEffect(ImageEffect):
    """Gives a colour tv static like effect, something real spooky"""

    def __init__(self, sigma, static_tv_image_path="./resources/tv_static.jpg"):
        self.__sigma = sigma
        self._tv_static_image: Image = Image.open(static_tv_image_path)
        super().__init__()

    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        img = context.img
        static_img = Image.effect_noise(self._tv_static_image.size, self.__sigma)

        self._tv_static_image.putalpha(static_img)

        resized = self._tv_static_image.resize(img.size)

        # we also need to convert the original image to RGBA if it is not already
        rgba_img = img.convert("RGBA")

        return Image.blend(rgba_img, resized, 0.3)


class SwirlFaceEffect(ImageEffect):
    def __init__(self, swirl_strength=5):
        self.__swirl_strength = swirl_strength
        super().__init__()

    def process_image(self, context: ImageProcessingContext) -> Image.Image:
        img = context.img

        for face in context.faces:
            top, right, bottom, left = face.get_bounding_box()

            # create a new image based on the current face
            face = Image.fromarray(context.img_data[top:bottom, left:right])

            # swirl the face
            processed_face = self.__swirl_rect(face, self.__swirl_strength)
            # add some alpha to the swirled image to make it less opaque
            processed_face.putalpha(100)
            # add a little bit of blur so that it is not so perfectly swirled
            processed_face = processed_face.filter(ImageFilter.GaussianBlur(2))

            # merge the face with the original image
            img.paste(processed_face, (left, top, right, bottom))

        return img

    def __swirl_rect(self, img: Image.Image, swirl_strength: int) -> Image.Image:
        # create a copy so that the orignal is not changed
        # not necessarily needed, but does avoid weird side effects
        to_swirl = img.copy()
        ellipse_mask = Image.new("L", to_swirl.size, 0)

        left = 0
        top = 0
        bottom = to_swirl.height - 1
        right = to_swirl.width - 1

        semimajor_axis = (bottom - top) / 2
        semiminor_axis = (right - left) / 2

        centerx = int(right / 2)
        centery = int(bottom / 2)

        for x in range(left, right):
            for y in range(top, bottom):
                # 1) convert to u,v space
                u = x - centerx
                v = y - centery

                # if w are at the center point of the swirl, do nothing
                if u == 0 and v == 0:
                    continue

                # 2) get the distance from pixel to the center and the angle
                # c = sqrt(u^2 + v^2)
                # thanks pythagoreous
                c = math.sqrt(math.pow(u, 2) + math.pow(v, 2))
                theta_radians = math.atan2(v, u)
                a = semiminor_axis  # horizontal axis
                b = semimajor_axis  # vertical axis
                # https://math.stackexchange.com/questions/432902/how-to-get-the-radius-of-an-ellipse-at-a-specific-angle-by-knowing-its-semi-majo
                ellipse_radius = (a * b) / math.sqrt(
                    (a * a) * (np.sin(theta_radians) * np.sin(theta_radians))
                    + (b * b) * (np.cos(theta_radians) * np.cos(theta_radians))
                )

                # 3) figure out if we should apply the swirl
                swirl_amount = 1 - (c / ellipse_radius)
                if swirl_amount <= 0:
                    continue

                # 3) find the angle to move the current pixel to. For pixels
                # closer to the centre, we want them to be more manipulated
                # (which is what the swirl amount is for), further pixels from
                # the centre should be not swirled as much
                twist_angle = ((random.randint(98, 102) / 100) * swirl_strength) * swirl_amount * math.pi * 2

                # 4) add the angle to twist to the current angle where the
                # pixel is located from centre
                theta_radians += twist_angle

                # 5) convert back to standard x,y coordinates
                new_x = math.cos(theta_radians) * c
                new_y = math.sin(theta_radians) * c

                # 6) update the current x,y pixel to have the RGB values of
                # the new calculated pixel based on the above math
                new_pixel = img.getpixel((new_x, new_y))
                to_swirl.putpixel((x, y), new_pixel)

        swirl_copy = to_swirl.copy()
        swirl_copy = swirl_copy.filter(ImageFilter.GaussianBlur(2))

        mask_blur = ellipse_mask.filter(ImageFilter.GaussianBlur(2))

        return Image.composite(to_swirl, swirl_copy, mask_blur)


class SketchyEyeEffect(ImageEffect):
    def __init__(self):
        super().__init__()

    def process_image(self, context: ImageProcessingContext):

        img = context.img
        draw = ImageDraw.Draw(img)

        for face in context.faces:

            for eye in face.get_eye_points():
                center_x, center_y, radius = self.__get_eye_dimensions(face.get_bounding_box(), eye)

                draw.ellipse(
                    [(center_x - radius, center_y - radius), (center_x + radius, center_y + radius)], (0, 0, 0, 100)
                )

        return img

    def __get_eye_dimensions(self, face_bounding_box: (int, int, int, int), eye_points: [(int, int)]) -> (int, int):
        face_top, face_right, face_bottom, face_left = face_bounding_box

        # find center of the each eye
        min_x = face_right
        max_x = face_left
        min_y = face_bottom
        max_y = face_top
        for (x, y) in eye_points:
            min_x = min(min_x, x)
            max_x = max(max_x, x)
            min_y = min(min_y, y)
            max_y = max(max_y, y)

        center_x = min_x + ((max_x - min_x) / 2)
        center_y = min_y + ((max_y - min_y) / 2)
        radius = max((max_x - min_x) / 2, (max_y - min_y) / 2)

        return center_x, center_y, radius

for this, we need to:             1. Create an all blank image the size of the passed in image             2. Paste onto it all the ghost images we want             3. Create a mask that is all blank, white where we want the image             4. Composite ghost sheet onto OG image with mask             5. ...             6. profit? # create the base ghost image # # Create mask that has the same setup # 'a' is the alpha value of the combinaton of all ghosts images at point x,y # An alpha of 0 means the pixel is transparent, which we use to create an image # mask only where the ghost pixels are located Get all locations to put a ghost image          For a given input image, determine how many ghost images to put on the         image, and gives their top+left x,y coordinates          Parameters:         img (Image.Image): The image we want to add ghosts too          Returns:         [(Image.Image, int, int)]: list of ghosts including the (x,y)                                    coordinates for the top+left location to                                    place it on                                    on the original image # use the max ghost image width and the number of images to determine # how many ghosts to place # we dont want to overload the image with ghosts # for the x location, we need to divide the main image width by # the number of ghosts to get where to put the ghost, and then # pick a random spot within that range # ex: two ghosts (x is final ghost location): # |           image           | # | ghost range | ghost range | # | x           |       x     | # using the bounds of the face, draw a red box around it! Gives a colour tv static like effect, something real spooky # we also need to convert the original image to RGBA if it is not already # create a new image based on the current face # swirl the face # add some alpha to the swirled image to make it less opaque # add a little bit of blur so that it is not so perfectly swirled # merge the face with the original image # create a copy so that the orignal is not changed # not necessarily needed, but does avoid weird side effects # 1) convert to u,v space # if w are at the center point of the swirl, do nothing # 2) get the distance from pixel to the center and the angle # c = sqrt(u^2 + v^2) # thanks pythagoreous # horizontal axis # vertical axis # https://math.stackexchange.com/questions/432902/how-to-get-the-radius-of-an-ellipse-at-a-specific-angle-by-knowing-its-semi-majo # 3) figure out if we should apply the swirl # 3) find the angle to move the current pixel to. For pixels # closer to the centre, we want them to be more manipulated # (which is what the swirl amount is for), further pixels from # the centre should be not swirled as much # 4) add the angle to twist to the current angle where the # pixel is located from centre # 5) convert back to standard x,y coordinates # 6) update the current x,y pixel to have the RGB values of # the new calculated pixel based on the above math # find center of the each eye