from __future__ import annotations

import base64
import os
import re
import tempfile
import unicodedata
from copy import deepcopy
from io import BytesIO
from pathlib import Path, PurePath
from typing import IO, TYPE_CHECKING, BinaryIO, Iterator, List, Optional, Tuple, Union, cast

import cv2
import numpy as np
import pdf2image
from PIL import Image
from unstructured_inference.inference.layout import convert_pdf_to_image as render_pdf_to_image
from unstructured_inference.inference.pdf_image import PdfRenderTooLargeError

from unstructured.documents.elements import ElementType
from unstructured.errors import UnprocessableEntityError
from unstructured.logger import logger
from unstructured.partition.common.common import convert_to_bytes, exactly_one
from unstructured.partition.utils.config import env_config

if TYPE_CHECKING:
    from unstructured_inference.inference.elements import TextRegion
    from unstructured_inference.inference.layout import DocumentLayout, PageLayout
    from unstructured_inference.inference.layoutelement import LayoutElement

    from unstructured.documents.elements import Element


def write_image(image: Union[Image.Image, np.ndarray], output_image_path: str):
    """
    Write an image to a specified file path, supporting both PIL Image and numpy ndarray formats.

    Parameters:
    - image (Union[Image.Image, np.ndarray]): The image to be written, which can be in PIL Image
      format or a numpy ndarray format.
    - output_image_path (str): The path to which the image will be written.

    Raises:
    - ValueError: If the provided image type is neither PIL Image nor numpy ndarray.

    Returns:
    - None: The function writes the image to the specified path but does not return any value.
    """

    if isinstance(image, Image.Image):
        image.save(output_image_path)
    elif isinstance(image, np.ndarray):
        cv2.imwrite(output_image_path, image)
    else:
        raise ValueError("Unsupported Image Type")


def convert_pdf_to_image(
    filename: str,
    file: Optional[Union[bytes, BinaryIO]] = None,
    dpi: Optional[int] = None,
    output_folder: Optional[Union[str, PurePath]] = None,
    path_only: bool = False,
    password: Optional[str] = None,
) -> Union[List[Image.Image], List[str]]:
    exactly_one(filename=filename, file=file)

    if dpi is None:
        dpi = env_config.PDF_RENDER_DPI

    try:
        return render_pdf_to_image(
            filename=filename,
            file=file,
            dpi=dpi,
            output_folder=output_folder,
            path_only=path_only,
            password=password,
            pdf_render_max_pixels_per_page=env_config.PDF_RENDER_MAX_PIXELS_PER_PAGE,
        )
    except PdfRenderTooLargeError as exc:
        raise UnprocessableEntityError(str(exc)) from exc


def pad_element_bboxes(
    element: "LayoutElement",
    padding: Union[int, float],
) -> "LayoutElement":
    """Increases (or decreases, if padding is negative) the size of the bounding
    boxes of the element by extending the boundary outward (resp. inward)"""

    out_element = deepcopy(element)
    out_element.bbox.x1 -= padding
    out_element.bbox.x2 += padding
    out_element.bbox.y1 -= padding
    out_element.bbox.y2 += padding

    return out_element


def pad_bbox(
    bbox: Tuple[float, float, float, float],
    padding: Tuple[Union[int, float], Union[int, float]],
) -> Tuple[float, float, float, float]:
    """Pads a bounding box (bbox) by a specified horizontal and vertical padding."""

    x1, y1, x2, y2 = bbox
    h_padding, v_padding = padding
    x1 -= h_padding
    x2 += h_padding
    y1 -= v_padding
    y2 += v_padding

    return x1, y1, x2, y2


def save_elements(
    elements: List["Element"],
    starting_page_number: int,
    element_category_to_save: str,
    pdf_image_dpi: int,
    filename: str = "",
    file: bytes | IO[bytes] | None = None,
    is_image: bool = False,
    extract_image_block_to_payload: bool = False,
    output_dir_path: str | None = None,
    password: Optional[str] = None,
):
    """
    Saves specific elements from a PDF as images either to a directory or embeds them in the
    element's payload.

    This function processes a list of elements partitioned from a PDF file. For each element of
    a specified category, it extracts and saves the image. The images can either be saved to
    a specified directory or embedded into the element's payload as a base64-encoded string.
    """

    # Determine the output directory path
    if not extract_image_block_to_payload:
        output_dir_path = output_dir_path or (
            str(Path(env_config.GLOBAL_WORKING_PROCESS_DIR) / "figures")
            if env_config.GLOBAL_WORKING_DIR_ENABLED
            else str(Path.cwd() / "figures")
        )

        os.makedirs(output_dir_path, exist_ok=True)

    with tempfile.TemporaryDirectory() as temp_dir:
        if is_image:
            if file is None:
                image_paths = [filename]
            else:
                if isinstance(file, bytes):
                    file_data = file
                else:
                    file.seek(0)
                    file_data = file.read()

                tmp_file_path = os.path.join(temp_dir, "tmp_file")
                with open(tmp_file_path, "wb") as tmp_file:
                    tmp_file.write(file_data)
                image_paths = [tmp_file_path]
        else:
            _image_paths = convert_pdf_to_image(
                filename,
                file,
                pdf_image_dpi,
                output_folder=temp_dir,
                path_only=True,
                password=password,
            )
            image_paths = cast(List[str], _image_paths)

        figure_number = 0
        for el in elements:
            if el.category != element_category_to_save:
                continue

            coordinates = el.metadata.coordinates
            if not coordinates or not coordinates.points:
                continue

            points = coordinates.points
            x1, y1 = points[0]
            x2, y2 = points[2]
            h_padding = env_config.EXTRACT_IMAGE_BLOCK_CROP_HORIZONTAL_PAD
            v_padding = env_config.EXTRACT_IMAGE_BLOCK_CROP_VERTICAL_PAD
            padded_bbox = cast(
                Tuple[int, int, int, int], pad_bbox((x1, y1, x2, y2), (h_padding, v_padding))
            )

            # The page number in the metadata may have been offset
            # by starting_page_number. Make sure we use the right
            # value for indexing!
            assert el.metadata.page_number
            metadata_page_number = el.metadata.page_number
            page_index = metadata_page_number - starting_page_number

            figure_number += 1
            try:
                image_path = image_paths[page_index]
                image = Image.open(image_path)
                cropped_image = image.crop(padded_bbox)

                # PNG images with transparency need to be converted before saving
                if cropped_image.mode == "RGBA":
                    cropped_image = cropped_image.convert("RGB")

                if extract_image_block_to_payload:
                    buffered = BytesIO()
                    cropped_image.save(buffered, format="JPEG")
                    img_base64 = base64.b64encode(buffered.getvalue())
                    img_base64_str = img_base64.decode()
                    el.metadata.image_base64 = img_base64_str
                    el.metadata.image_mime_type = "image/jpeg"
                else:
                    basename = "table" if el.category == ElementType.TABLE else "figure"
                    assert output_dir_path
                    output_f_path = os.path.join(
                        output_dir_path,
                        f"{basename}-{metadata_page_number}-{figure_number}.jpg",
                    )
                    write_image(cropped_image, output_f_path)
                    # add image path to element metadata
                    el.metadata.image_path = output_f_path
            except (ValueError, IOError):
                logger.warning("Image Extraction Error: Skipping the failed image", exc_info=True)


def check_element_types_to_extract(
    extract_image_block_types: Optional[List[str]],
) -> List[str]:
    """Check and normalize the provided list of element types to extract."""

    if extract_image_block_types is None:
        return []

    if not isinstance(extract_image_block_types, list):
        raise TypeError(
            "The extract_image_block_types parameter must be a list of element types as strings, "
            "ex. ['Table', 'Image']",
        )

    available_element_types = {e_type.lower(): e_type for e_type in ElementType.to_dict().values()}
    normalized_extract_image_block_types = []
    for el_type in extract_image_block_types:
        normalized_el_type = available_element_types.get(
            el_type.lower(), el_type.lower().capitalize()
        )
        if normalized_el_type not in available_element_types.values():
            logger.warning(f"The requested type ({el_type}) doesn't match any available type")
        normalized_extract_image_block_types.append(normalized_el_type)

    return normalized_extract_image_block_types


def valid_text(text: str) -> bool:
    """a helper that determines if the text is valid ascii text"""
    if not text:
        return False
    return "(cid:" not in text


def cid_ratio(text: str) -> float:
    """Gets ratio of unknown 'cid' characters extracted from text to all characters."""
    if not is_cid_present(text):
        return 0.0
    cid_pattern = r"\(cid\:(\d+)\)"
    unmatched, n_cid = re.subn(cid_pattern, "", text)
    total = n_cid + len(unmatched)
    return n_cid / total


def is_cid_present(text: str) -> bool:
    """Checks if a cid code is present in a text selection."""
    if len(text) < len("(cid:x)"):
        return False
    return text.find("(cid:") != -1


def annotate_layout_elements_with_image(
    inferred_page_layout: "PageLayout",
    extracted_page_layout: Optional["PageLayout"],
    output_dir_path: str,
    output_f_basename: str,
    page_number: int,
):
    """
     Annotates a page image with both inferred and extracted layout elements.

    This function takes the layout elements of a single page, either extracted from or inferred
    for the document, and annotates them on the page image. It creates two separate annotated
    images, one for each set of layout elements: 'inferred' and 'extracted'.
    These annotated images are saved to a specified directory.
    """

    layout_map = {"inferred": {"layout": inferred_page_layout, "color": "blue"}}
    if extracted_page_layout:
        layout_map["extracted"] = {"layout": extracted_page_layout, "color": "green"}

    for label, layout_data in layout_map.items():
        page_layout = layout_data.get("layout")
        color = layout_data.get("color")

        img = page_layout.annotate(colors=color)
        output_f_path = os.path.join(
            output_dir_path, f"{output_f_basename}_{page_number}_{label}.jpg"
        )
        write_image(img, output_f_path)
        print(f"output_image_path: {output_f_path}")


def annotate_layout_elements(
    inferred_document_layout: "DocumentLayout",
    extracted_layout: List["TextRegion"],
    filename: str,
    output_dir_path: str,
    pdf_image_dpi: int,
    is_image: bool = False,
) -> None:
    """
    Annotates layout elements on images extracted from a PDF or an image file.

    This function processes a given document (PDF or image) and annotates layout elements based
    on the inferred and extracted layout information.
    It handles both PDF documents and standalone image files. For PDFs, it converts each page
    into an image, whereas for image files, it processes the single image.
    """

    from unstructured_inference.inference.layout import PageLayout

    output_f_basename = os.path.splitext(os.path.basename(filename))[0]
    images = []
    try:
        if is_image:
            with Image.open(filename) as img:
                img = img.convert("RGB")
                images.append(img)

                extracted_page_layout = None
                if extracted_layout:
                    extracted_page_layout = PageLayout(
                        number=1,
                        image=img,
                    )
                    extracted_page_layout.elements = extracted_layout[0]

                inferred_page_layout = inferred_document_layout.pages[0]
                inferred_page_layout.image = img

                annotate_layout_elements_with_image(
                    inferred_page_layout=inferred_document_layout.pages[0],
                    extracted_page_layout=extracted_page_layout,
                    output_dir_path=output_dir_path,
                    output_f_basename=output_f_basename,
                    page_number=1,
                )
        else:
            with tempfile.TemporaryDirectory() as temp_dir:
                _image_paths = convert_pdf_to_image(
                    filename,
                    dpi=pdf_image_dpi,
                    output_folder=temp_dir,
                    path_only=True,
                )
                image_paths = cast(List[str], _image_paths)
                for i, image_path in enumerate(image_paths):
                    with Image.open(image_path) as img:
                        page_number = i + 1

                        extracted_page_layout = None
                        if extracted_layout:
                            extracted_page_layout = PageLayout(
                                number=page_number,
                                image=img,
                            )
                            extracted_page_layout.elements = extracted_layout[i]

                        inferred_page_layout = inferred_document_layout.pages[i]
                        inferred_page_layout.image = img

                        annotate_layout_elements_with_image(
                            inferred_page_layout=inferred_document_layout.pages[i],
                            extracted_page_layout=extracted_page_layout,
                            output_dir_path=output_dir_path,
                            output_f_basename=output_f_basename,
                            page_number=page_number,
                        )
    except Exception as e:
        if os.path.isdir(filename) or os.path.isfile(filename):
            raise e
        else:
            raise FileNotFoundError(f'File "{filename}" not found!') from e


def convert_pdf_to_images(
    filename: str = "",
    file: Optional[bytes | IO[bytes]] = None,
    chunk_size: int = 10,
    password: Optional[str] = None,
) -> Iterator[Image.Image]:
    # Convert a PDF in small chunks of pages at a time (e.g. 1-10, 11-20... and so on)
    exactly_one(filename=filename, file=file)
    if file is not None:
        f_bytes = convert_to_bytes(file)
        info = pdf2image.pdfinfo_from_bytes(f_bytes, userpw=password)
    else:
        f_bytes = None
        info = pdf2image.pdfinfo_from_path(filename, userpw=password)

    total_pages = info["Pages"]
    for start_page in range(1, total_pages + 1, chunk_size):
        end_page = min(start_page + chunk_size - 1, total_pages)
        try:
            chunk_images = render_pdf_to_image(
                filename=filename if f_bytes is None else None,
                file=f_bytes,
                dpi=env_config.PDF_RENDER_DPI,
                first_page=start_page,
                last_page=end_page,
                password=password,
                pdf_render_max_pixels_per_page=env_config.PDF_RENDER_MAX_PIXELS_PER_PAGE,
            )
        except PdfRenderTooLargeError as exc:
            raise UnprocessableEntityError(str(exc)) from exc
        chunk_images = cast(List[Image.Image], chunk_images)

        for image in chunk_images:
            yield image


def remove_control_characters(text: str) -> str:
    """Removes control characters from text."""

    # Replace newline character with a space
    text = text.replace("\t", " ").replace("\n", " ")
    # Remove other control characters
    out_text = "".join(c for c in text if unicodedata.category(c)[0] != "C")
    return out_text
