import logging
import math
import tempfile
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from io import BytesIO
from pathlib import Path
from typing import Any, Generator, List, Optional, TypeVar, Union

import numpy as np
from matplotlib import colors, font_manager
from numba import njit
from PIL import Image, ImageDraw, ImageFont
from unstructured_inference.constants import ElementType

from unstructured.partition.pdf_image.analysis.processor import AnalysisProcessor
from unstructured.partition.pdf_image.pdf_image_utils import convert_pdf_to_image

PageImage = TypeVar("PageImage", Image.Image, np.ndarray)


def get_font():
    preferred_fonts = ["Arial.ttf"]
    available_fonts = font_manager.findSystemFonts()
    if not available_fonts:
        raise ValueError("No fonts available")
    for font in preferred_fonts:
        for available_font in available_fonts:
            if font in available_font:
                return available_font
    return available_fonts[0]


COLOR_WHITE = ("white", (255, 255, 255))
COLOR_BLACK = ("black", (0, 0, 0))


class TextAlignment(Enum):
    TOP_LEFT = "top_left"
    TOP_RIGHT = "top_right"
    BOTTOM_LEFT = "bottom_left"
    BOTTOM_RIGHT = "bottom_right"
    CENTER = "center"


@dataclass
class BboxLabels:
    top_left: Optional[str] = None
    top_right: Optional[str] = None
    bottom_left: Optional[str] = None
    bottom_right: Optional[str] = None
    center: Optional[str] = None


@dataclass
class BBox:
    points: tuple[int, int, int, int]
    labels: Optional[BboxLabels] = None


def get_rgb_color(color: str) -> tuple[int, int, int]:
    """Convert a color name to RGB values.

    Args:
        color: A color name supported by matplotlib.

    Returns:
        A tuple of three integers representing the RGB values of the color.
    """
    try:
        rgb_colors = colors.to_rgb(color)
    except ValueError:
        print("Error")
        raise
    return int(rgb_colors[0] * 255), int(rgb_colors[1] * 255), int(rgb_colors[2] * 255)


@njit(cache=True, fastmath=True)
def _get_bbox_to_page_ratio(bbox: tuple[int, int, int, int], page_size: tuple[int, int]) -> float:
    """Compute the ratio of the bounding box to the page size.

    Args:
        bbox: Tuple containing coordinates of the bbox: (x1, y1, x2, y2).
        page_size: Tuple containing page size: (width, height).

    Returns:
        The ratio of the bounding box to the page size.
    """
    x1, y1, x2, y2 = bbox
    page_width, page_height = page_size
    page_diagonal = math.sqrt(page_height**2 + page_width**2)
    bbox_width = x2 - x1
    bbox_height = y2 - y1
    bbox_diagonal = math.sqrt(bbox_height**2 + bbox_width**2)
    return bbox_diagonal / page_diagonal


def _get_optimal_value_for_bbox(
    bbox: tuple[int, int, int, int],
    page_size: tuple[int, int],
    min_value: int,
    max_value: int,
    ratio_for_min_value: float = 0.01,
    ratio_for_max_value: float = 0.5,
) -> int:
    """Compute the optimal value for a given bounding box using a linear function
    generated for given min and max values and ratios

    Args:
        bbox: Tuple containing coordinates of the bbox: (x1, y1, x2, y2).
        page_size: Tuple containing page size: (width, height).
        min_value: The minimum value returned by the function.
        max_value: The maximum value returned by the function.
        ratio_for_min_value: The ratio of the bbox to page size for the min value.
        ratio_for_max_value: The ratio of the bbox to page size for the max value.

    Returns:
        The optimal value for the given bounding box and parameters given.
    """
    bbox_to_page_ratio = _get_bbox_to_page_ratio(bbox, page_size)
    slope, intercept = _linear_polyfit_2point(
        ratio_for_min_value, ratio_for_max_value, min_value, max_value
    )
    value = int(bbox_to_page_ratio * slope + intercept)
    return max(min_value, min(max_value, value))


def get_bbox_text_size(
    bbox: tuple[int, int, int, int],
    page_size: tuple[int, int],
    min_font_size: int = 16,
    max_font_size: int = 32,
) -> int:
    """Compute the optimal font size for a given bounding box.

    Args:
        bbox: Tuple containing coordinates of the bbox: (x1, y1, x2, y2).
        page_size: Tuple containing page size: (width, height).
        min_font_size: The minimum font size returned by the function.
        max_font_size: The maximum font size returned by the function.

    Returns:
        The optimal font size for the given bounding box.
    """
    return _get_optimal_value_for_bbox(
        bbox=bbox,
        page_size=page_size,
        min_value=min_font_size,
        max_value=max_font_size,
    )


def get_bbox_thickness(
    bbox: tuple[int, int, int, int],
    page_size: tuple[int, int],
    min_thickness: int = 1,
    max_thickness: int = 4,
) -> float:
    """Compute the optimal thickness for a given bounding box.

    Args:
        bbox: Tuple containing coordinates of the bbox: (x1, y1, x2, y2).
        page_size: Tuple containing page size: (width, height).
        min_thickness: The minimum font size returned by the function.
        max_thickness: The maximum font size returned by the function.

    Returns:

    """
    return _get_optimal_value_for_bbox(
        bbox=bbox,
        page_size=page_size,
        min_value=min_thickness,
        max_value=max_thickness,
    )


def get_text_color(
    background_color: Union[str, tuple[int, int, int]], brightness_threshold: float = 0.5
) -> tuple[str, tuple[int, int, int]]:
    """Returns the contrastive text color (black or white) for a given background color.

    Args:
        background_color: Tuple containing RGB values of the background color.

    Returns:
        Tuple containing RGB values of the text color.
    """
    if isinstance(background_color, str):
        background_color = get_rgb_color(background_color)
    background_brightness = (
        0.299 * background_color[0] + 0.587 * background_color[1] + 0.114 * background_color[0]
    ) / 255
    if background_brightness > brightness_threshold:
        return COLOR_BLACK
    else:
        return COLOR_WHITE


def get_label_rect_and_coords(
    alignment: TextAlignment,
    bbox_points: tuple[int, int, int, int],
    text_width: int,
    text_height: int,
):
    indent = max(int(text_width * 0.2), 10)
    vertical_correction = max(int(text_height * 0.3), 10)

    # with this the text should be centered in the rectangle
    rect_width = text_width + indent * 2
    # we apply a correction to the height to make the text not overlap over the rectangle
    # (the text height getter looks to return too small value)
    rect_height = text_height + vertical_correction
    x1, y1, x2, y2 = bbox_points
    if alignment is TextAlignment.CENTER:
        # center:
        horizontal_half = int(rect_width / 2 * 1.05)
        vertical_half = int(rect_height / 2 * 1.05)
        center_point = x1 + (x2 - x1) // 2, y1 + (y2 - y1) // 2
        # resize rectangle to make it look better in the center
        label_rectangle = (
            (
                center_point[0] - horizontal_half,
                center_point[1] - vertical_half,
            ),
            (
                center_point[0] + horizontal_half,
                center_point[1] + vertical_half,
            ),
        )
        label_coords = (
            center_point[0] - horizontal_half + int(indent * 1.05),
            center_point[1] - vertical_half * 1.05,
        )
    elif alignment is TextAlignment.TOP_LEFT:
        label_rectangle = (
            (x1, y1 - rect_height),
            (x1 + rect_width, y1),
        )
        label_coords = (x1 + indent, y1 - rect_height)
    elif alignment is TextAlignment.TOP_RIGHT:
        label_rectangle = (
            (x2 - rect_width, y1),
            (x2, y1 + rect_height),
        )
        label_coords = (x2 - text_width - indent, y1)
    elif alignment is TextAlignment.BOTTOM_LEFT:
        label_rectangle = (
            (x1, y2 - rect_height),
            (x1 + rect_width, y2),
        )
        label_coords = (x1 + indent, y2 - rect_height)
    elif alignment is TextAlignment.BOTTOM_RIGHT:
        label_rectangle = (
            (x2 - rect_width, y2 - rect_height),
            (x2, y2),
        )
        label_coords = (x2 - text_width - indent, y2 - rect_height)
    else:
        raise ValueError(f"Unknown alignment {alignment}")
    return label_rectangle, label_coords


def draw_bbox_label(
    image_draw: ImageDraw.ImageDraw,
    text: str,
    bbox_points: tuple[int, int, int, int],
    alignment: TextAlignment,
    font_size: int,
    background_color: str,
):
    """Draw a label stick to a bounding box.
    The alignment parameter specifies where the label should be placed.

    Args:
        image_draw:         ImageDraw object to draw on the image.
        text:               Text to draw.
        bbox_points:        Bounding box points.
        alignment:          Text alignment.
        font_size:          Font size of the text.
        background_color:   RGB values of the background color.
    """
    font = ImageFont.truetype(get_font(), font_size)
    text_x1, text_y1, text_x2, text_y2 = image_draw.textbbox(
        (0, 0), text, font=font, align="center"
    )
    text_width = text_x2 - text_x1
    text_height = text_y2 - text_y1

    label_rectangle, label_coords = get_label_rect_and_coords(
        alignment, bbox_points, text_width, text_height
    )

    rgb_background_color = get_rgb_color(background_color)
    try:
        image_draw.rectangle(
            label_rectangle,
            fill=background_color,
            outline=background_color,
        )
    except TypeError:
        image_draw.rectangle(
            label_rectangle,
            fill=rgb_background_color,
            outline=rgb_background_color,
        )
    text_color, text_color_rgb = get_text_color(background_color)
    try:
        image_draw.text(label_coords, text, fill=text_color, font=font, align="center")
    except TypeError:
        image_draw.text(label_coords, text, fill=text_color_rgb, font=font, align="center")


def draw_bbox_on_image(
    image_draw: ImageDraw.ImageDraw,
    bbox: BBox,
    color: str,
):
    """Draw bbox with additional labels on the image..

    Args:
        image_draw:     ImageDraw object to draw on the image.
        bbox:           Bounding box to draw.
        color:          RGB values of the color of the bounding box (edges + label backgrounds).
    """
    x1, y1, x2, y2 = bbox.points
    if x1 >= x2 or y1 >= y2:
        print(f"Invalid bbox coordinates: {bbox.points}")
        return
    top_left = x1, y1  # the main
    bottom_right = x2, y2
    box_thickness = get_bbox_thickness(bbox=bbox.points, page_size=image_draw.im.size)
    font_size = get_bbox_text_size(bbox=bbox.points, page_size=image_draw.im.size)

    try:
        image_draw.rectangle((top_left, bottom_right), outline=color, width=box_thickness)
    except TypeError:
        rgb_color = get_rgb_color(color)
        image_draw.rectangle((top_left, bottom_right), outline=rgb_color, width=box_thickness)

    if bbox.labels is not None:
        if top_left_label := bbox.labels.top_left:
            draw_bbox_label(
                image_draw,
                top_left_label,
                bbox_points=bbox.points,
                alignment=TextAlignment.TOP_LEFT,
                font_size=font_size,
                background_color=color,
            )
        if top_right_label := bbox.labels.top_right:
            draw_bbox_label(
                image_draw,
                top_right_label,
                bbox_points=bbox.points,
                alignment=TextAlignment.TOP_RIGHT,
                font_size=font_size,
                background_color=color,
            )
        if bottom_left_label := bbox.labels.bottom_left:
            draw_bbox_label(
                image_draw,
                bottom_left_label,
                bbox_points=bbox.points,
                alignment=TextAlignment.BOTTOM_LEFT,
                font_size=font_size,
                background_color=color,
            )
        if bottom_right_label := bbox.labels.bottom_right:
            draw_bbox_label(
                image_draw,
                bottom_right_label,
                bbox_points=bbox.points,
                alignment=TextAlignment.BOTTOM_RIGHT,
                font_size=font_size,
                background_color=color,
            )
        if center_label := bbox.labels.center:
            draw_bbox_label(
                image_draw,
                center_label,
                bbox_points=bbox.points,
                alignment=TextAlignment.CENTER,
                font_size=font_size * 2,
                background_color=color,
            )


@njit(cache=True, fastmath=True)
def _linear_polyfit_2point(x0: float, x1: float, y0: float, y1: float):
    """Compute slope and intercept for a line passing through (x0, y0), (x1, y1)."""
    if x1 == x0:
        slope = 0.0
        intercept = (y0 + y1) / 2.0
    else:
        slope = (y1 - y0) / (x1 - x0)
        intercept = y0 - slope * x0
    return slope, intercept


class LayoutDrawer(ABC):
    layout_source: str = "unknown"
    laytout_dump: dict

    def __init__(self, layout_dump: dict):
        self.layout_dump = layout_dump

    def draw_layout_on_page(self, page_image: Image.Image, page_num: int) -> Image.Image:
        """Draw the layout bboxes with additional metadata on the image."""
        layout_pages = self.layout_dump.get("pages")
        if not layout_pages:
            print(f"Warning: layout in drawer {self.__class__.__name__} is empty - skipping")
            return page_image
        if len(layout_pages) < page_num:
            print(f"Error! Page {page_num} not found in layout (pages: {len(layout_pages)})")
            return page_image
        image_draw = ImageDraw.ImageDraw(page_image)
        page_layout_dump = layout_pages[page_num - 1]
        if page_num != page_layout_dump.get("number"):
            dump_page_num = page_layout_dump.get("number")
            print(f"Warning: Requested page num {page_num} differs from dump {dump_page_num}")
        for idx, elements in enumerate(page_layout_dump["elements"], 1):
            self.render_element_on_page(idx, image_draw, elements)
        return page_image

    @abstractmethod
    def render_element_on_page(self, idx: int, image_draw: ImageDraw, elements: dict[str, Any]):
        """Draw a single element on the image."""


class SimpleLayoutDrawer(LayoutDrawer, ABC):
    color: str
    show_order: bool = False
    show_text_length: bool = False

    def render_element_on_page(self, idx: int, image_draw: ImageDraw, elements: dict[str, Any]):
        text_len = len(elements["text"]) if elements.get("text") else 0
        element_prob = elements.get("prob")
        element_order = f"{idx}" if self.show_order else None
        text_len = f"len: {text_len}" if self.show_text_length else None
        bbox = BBox(
            points=elements["bbox"],
            labels=BboxLabels(
                top_right=f"prob: {element_prob:.2f}" if element_prob else None,
                bottom_left=text_len,
                center=element_order,
            ),
        )
        draw_bbox_on_image(image_draw, bbox, color=self.color)


class PdfminerLayoutDrawer(SimpleLayoutDrawer):
    layout_source = "pdfminer"

    def __init__(self, layout_dump: dict, color: str = "red"):
        self.layout_dump = layout_dump
        self.color = color
        self.show_order = True
        super().__init__(layout_dump)


class OCRLayoutDrawer(SimpleLayoutDrawer):
    layout_source = "ocr"

    def __init__(self, layout_dump: dict, color: str = "red"):
        self.color = color
        self.show_order = False
        self.show_text_length = False
        super().__init__(layout_dump)


class ODModelLayoutDrawer(LayoutDrawer):
    layout_source = "od_model"

    color_map = {
        ElementType.CAPTION: "salmon",
        ElementType.FOOTNOTE: "orange",
        ElementType.FORMULA: "mediumpurple",
        ElementType.LIST_ITEM: "navy",
        ElementType.PAGE_FOOTER: "deeppink",
        ElementType.PAGE_HEADER: "green",
        ElementType.PICTURE: "sienna",
        ElementType.SECTION_HEADER: "darkorange",
        ElementType.TABLE: "blue",
        ElementType.TEXT: "turquoise",
        ElementType.TITLE: "greenyellow",
    }

    def render_element_on_page(self, idx: int, image_draw: ImageDraw, elements: dict[str, Any]):
        element_type = elements["type"]
        element_prob = elements.get("prob")
        bbox_points = elements["bbox"]
        color = self.get_element_type_color(element_type)
        bbox = BBox(
            points=bbox_points,
            labels=BboxLabels(
                top_left=f"{element_type}",
                top_right=f"prob: {element_prob:.2f}" if element_prob else None,
            ),
        )
        draw_bbox_on_image(image_draw, bbox, color=color)

    def get_element_type_color(self, element_type: str) -> str:
        return self.color_map.get(element_type, "cyan")


class FinalLayoutDrawer(LayoutDrawer):
    layout_source = "final"

    color_map = {
        "CheckBox": "brown",
        "ListItem": "red",
        "Title": "greenyellow",
        "NarrativeText": "turquoise",
        "Header": "green",
        "Footer": "orange",
        "FigureCaption": "sienna",
        "Image": "sienna",
        "Table": "blue",
        "Address": "gold",
        "EmailAddress": "lightskyblue",
        "Formula": "mediumpurple",
        "CodeSnippet": "magenta",
        "PageNumber": "crimson",
    }

    def __init__(self, layout_dump: dict):
        self.layout_dump = layout_dump

    def render_element_on_page(self, idx: int, image_draw: ImageDraw, elements: dict[str, Any]):
        element_type = elements["type"]
        element_prob = elements.get("prob")
        text_len = len(elements["text"]) if elements.get("text") else 0
        bbox_points = elements["bbox"]
        color = self.get_element_type_color(element_type)
        cluster = elements.get("cluster")
        bbox = BBox(
            points=bbox_points,
            labels=BboxLabels(
                top_left=f"{element_type}",
                top_right=f"prob: {element_prob:.2f}" if element_prob else None,
                bottom_right=f"len: {text_len}",
                bottom_left=f"cl: {cluster}" if cluster else None,
                center=f"{idx}",
            ),
        )
        draw_bbox_on_image(image_draw, bbox, color=color)

    def get_element_type_color(self, element_type: str) -> str:
        return self.color_map.get(element_type, "cyan")


class AnalysisDrawer(AnalysisProcessor):
    def __init__(
        self,
        filename: Optional[Union[str, Path]],
        is_image: bool,
        save_dir: Union[str, Path],
        file: Optional[BytesIO] = None,
        draw_caption: bool = True,
        draw_grid: bool = False,
        resize: Optional[float] = None,
        format: str = "png",
    ):
        self.draw_caption = draw_caption
        self.draw_grid = draw_grid
        self.resize = resize
        self.is_image = is_image
        self.format = format
        self.drawers = []
        self.file = file

        super().__init__(filename, save_dir)

    def add_drawer(self, drawer: LayoutDrawer):
        self.drawers.append(drawer)

    def process(self):
        filename_stem = Path(self.filename).stem
        analysis_save_dir = Path(self.save_dir) / "analysis" / filename_stem / "bboxes"
        analysis_save_dir.mkdir(parents=True, exist_ok=True)

        for page_idx, orig_image_page in enumerate(self.load_source_image()):
            images_for_grid = []
            page_num = page_idx + 1
            for drawer in self.drawers:
                try:
                    image = drawer.draw_layout_on_page(orig_image_page.copy(), page_num=page_num)
                except:  # noqa: E722
                    logging.exception(
                        f"Error while drawing layout for page {page_num} "
                        f"for file {self.filename} with drawer "
                        f"{drawer.__class__.__name__}"
                    )
                    continue
                if self.draw_caption:
                    image = self.add_caption(
                        image, caption=f"Layout source: {drawer.layout_source}"
                    )
                if not self.draw_grid:
                    if self.resize is not None:
                        image = image.resize(
                            (int(image.width * self.resize), int(image.height * self.resize)),
                        )
                    image.save(
                        analysis_save_dir / f"page{page_num}"
                        f"_layout_{drawer.layout_source}.{self.format}",
                        optimize=True,
                        quality=85,
                    )
                    image.close()
                else:
                    images_for_grid.append(image)
            if images_for_grid:
                grid_image = self.paste_images_on_grid(images_for_grid)
                if self.resize is not None:
                    grid_image = grid_image.resize(
                        (int(grid_image.width * self.resize), int(grid_image.height * self.resize))
                    )
                grid_image.save(
                    analysis_save_dir / f"page{page_num}_layout_all.{self.format}",
                    optimize=True,
                    quality=85,
                )
                grid_image.close()

    def add_caption(self, image: Image.Image, caption: str):
        font = ImageFont.truetype(get_font(), 52)
        draw = ImageDraw.ImageDraw(image)
        text_x1, text_y1, text_x2, text_y2 = draw.textbbox(
            (0, 0), caption, font=font, align="center"
        )
        text_width = text_x2 - text_x1
        text_height = int((text_y2 - text_y1) * 1.5)
        text_xy = (image.width - text_width) // 2, 10
        caption_image = Image.new("RGB", (image.width, text_height), color=(255, 255, 255))
        caption_draw = ImageDraw.ImageDraw(caption_image)
        caption_draw.text(text_xy, caption, (0, 0, 0), font=font)

        expanded_image = Image.new("RGB", (image.width, image.height + text_height))
        expanded_image.paste(caption_image, (0, 0))
        expanded_image.paste(image, (0, text_height))
        image.close()
        return expanded_image

    def paste_images_on_grid(self, images: List[Image.Image]) -> Image.Image:
        """Creates a single image that presents all the images on a grid 2 x n/2"""

        pairs = []
        for i in range(0, len(images), 2):
            left_image = images[i]
            right_image = images[i + 1] if i < len(images) - 1 else None
            pairs.append((left_image, right_image))

        max_pair_width = max([pair[0].width + (pair[1].width if pair[1] else 0) for pair in pairs])
        sum_height = sum([max(pair[0].height, pair[1].height if pair[1] else 0) for pair in pairs])

        new_im = Image.new("RGB", (max_pair_width, sum_height))

        height_shift = 0
        for image_left, image_right in pairs:
            new_im.paste(image_left, (0, height_shift))
            if image_right:
                new_im.paste(image_right, (image_left.width, height_shift))
            height_shift += max(image_left.height, image_right.height if image_right else 0)

        for image in images:
            image.close()
        return new_im

    def load_source_image(self) -> Generator[Image.Image, None, None]:
        with tempfile.TemporaryDirectory() as temp_dir:
            image_paths = []
            if self.is_image:
                if self.file:
                    try:
                        image = Image.open(self.file)
                        output_file = Path(temp_dir) / self.filename
                        image.save(output_file, format="PNG")
                        image_paths = [output_file]
                    except Exception as ex:  # noqa: E722
                        print(
                            f"Error while converting image to PNG for file {self.filename}, "
                            f"exception: {ex}"
                        )
                else:
                    image_paths = [self.filename]
            else:
                try:
                    str_filename = (
                        ""
                        if (self.filename is None or self.file is not None)
                        else (
                            str(self.filename) if isinstance(self.filename, Path) else self.filename
                        )
                    )
                    image_paths = convert_pdf_to_image(
                        filename=str_filename,
                        file=self.file,
                        output_folder=temp_dir,
                        path_only=True,
                    )
                except Exception as ex:  # noqa: E722
                    print(
                        f"Error while converting pdf to image for file {self.filename}",
                        f"exception: {ex}",
                    )

            for image_path in image_paths:
                with Image.open(str(image_path)) as image:
                    yield image.convert("RGB")
