"""Provides objects that can characterize image streams.

That characterization is as to content type and size, as a required step in including
them in a document.
"""

from __future__ import annotations

import hashlib
import io
import os
from typing import IO, Tuple

from docx.image.exceptions import UnrecognizedImageError
from docx.shared import Emu, Inches, Length, lazyproperty


class Image:
    """Graphical image stream such as JPEG, PNG, or GIF with properties and methods
    required by ImagePart."""

    def __init__(self, blob: bytes, filename: str, image_header: BaseImageHeader):
        super(Image, self).__init__()
        self._blob = blob
        self._filename = filename
        self._image_header = image_header

    @classmethod
    def from_blob(cls, blob: bytes) -> Image:
        """Return a new |Image| subclass instance parsed from the image binary contained
        in `blob`."""
        stream = io.BytesIO(blob)
        return cls._from_stream(stream, blob)

    @classmethod
    def from_file(cls, image_descriptor: str | IO[bytes]):
        """Return a new |Image| subclass instance loaded from the image file identified
        by `image_descriptor`, a path or file-like object."""
        if isinstance(image_descriptor, str):
            path = image_descriptor
            with open(path, "rb") as f:
                blob = f.read()
                stream = io.BytesIO(blob)
            filename = os.path.basename(path)
        else:
            stream = image_descriptor
            stream.seek(0)
            blob = stream.read()
            filename = None
        return cls._from_stream(stream, blob, filename)

    @property
    def blob(self):
        """The bytes of the image 'file'."""
        return self._blob

    @property
    def content_type(self) -> str:
        """MIME content type for this image, e.g. ``'image/jpeg'`` for a JPEG image."""
        return self._image_header.content_type

    @lazyproperty
    def ext(self):
        """The file extension for the image.

        If an actual one is available from a load filename it is used. Otherwise a
        canonical extension is assigned based on the content type. Does not contain the
        leading period, e.g. 'jpg', not '.jpg'.
        """
        return os.path.splitext(self._filename)[1][1:]

    @property
    def filename(self):
        """Original image file name, if loaded from disk, or a generic filename if
        loaded from an anonymous stream."""
        return self._filename

    @property
    def px_width(self) -> int:
        """The horizontal pixel dimension of the image."""
        return self._image_header.px_width

    @property
    def px_height(self) -> int:
        """The vertical pixel dimension of the image."""
        return self._image_header.px_height

    @property
    def horz_dpi(self) -> int:
        """Integer dots per inch for the width of this image.

        Defaults to 72 when not present in the file, as is often the case.
        """
        return self._image_header.horz_dpi

    @property
    def vert_dpi(self) -> int:
        """Integer dots per inch for the height of this image.

        Defaults to 72 when not present in the file, as is often the case.
        """
        return self._image_header.vert_dpi

    @property
    def width(self) -> Inches:
        """A |Length| value representing the native width of the image, calculated from
        the values of `px_width` and `horz_dpi`."""
        return Inches(self.px_width / self.horz_dpi)

    @property
    def height(self) -> Inches:
        """A |Length| value representing the native height of the image, calculated from
        the values of `px_height` and `vert_dpi`."""
        return Inches(self.px_height / self.vert_dpi)

    def scaled_dimensions(
        self, width: int | Length | None = None, height: int | Length | None = None
    ) -> Tuple[Length, Length]:
        """(cx, cy) pair representing scaled dimensions of this image.

        The native dimensions of the image are scaled by applying the following rules to
        the `width` and `height` arguments.

        * If both `width` and `height` are specified, the return value is (`width`,
        `height`); no scaling is performed.
        * If only one is specified, it is used to compute a scaling factor that is then
        applied to the unspecified dimension, preserving the aspect ratio of the image.
        * If both `width` and `height` are |None|, the native dimensions are returned.

        The native dimensions are calculated using the dots-per-inch (dpi) value
        embedded in the image, defaulting to 72 dpi if no value is specified, as is
        often the case. The returned values are both |Length| objects.
        """
        if width is None and height is None:
            return self.width, self.height

        if width is None:
            assert height is not None
            scaling_factor = float(height) / float(self.height)
            width = round(self.width * scaling_factor)

        if height is None:
            scaling_factor = float(width) / float(self.width)
            height = round(self.height * scaling_factor)

        return Emu(width), Emu(height)

    @lazyproperty
    def sha1(self):
        """SHA1 hash digest of the image blob."""
        return hashlib.sha1(self._blob).hexdigest()

    @classmethod
    def _from_stream(
        cls,
        stream: IO[bytes],
        blob: bytes,
        filename: str | None = None,
    ) -> Image:
        """Return an instance of the |Image| subclass corresponding to the format of the
        image in `stream`."""
        image_header = _ImageHeaderFactory(stream)
        if filename is None:
            filename = "image.%s" % image_header.default_ext
        return cls(blob, filename, image_header)


def _ImageHeaderFactory(stream: IO[bytes]):
    """A |BaseImageHeader| subclass instance that can parse headers of image in `stream`."""
    from docx.image import SIGNATURES

    def read_32(stream: IO[bytes]):
        stream.seek(0)
        return stream.read(32)

    header = read_32(stream)
    for cls, offset, signature_bytes in SIGNATURES:
        end = offset + len(signature_bytes)
        found_bytes = header[offset:end]
        if found_bytes == signature_bytes:
            return cls.from_stream(stream)
    raise UnrecognizedImageError


class BaseImageHeader:
    """Base class for image header subclasses like |Jpeg| and |Tiff|."""

    def __init__(self, px_width: int, px_height: int, horz_dpi: int, vert_dpi: int):
        self._px_width = px_width
        self._px_height = px_height
        self._horz_dpi = horz_dpi
        self._vert_dpi = vert_dpi

    @property
    def content_type(self) -> str:
        """Abstract property definition, must be implemented by all subclasses."""
        msg = "content_type property must be implemented by all subclasses of BaseImageHeader"
        raise NotImplementedError(msg)

    @property
    def default_ext(self) -> str:
        """Default filename extension for images of this type.

        An abstract property definition, must be implemented by all subclasses.
        """
        raise NotImplementedError(
            "default_ext property must be implemented by all subclasses of BaseImageHeader"
        )

    @property
    def px_width(self):
        """The horizontal pixel dimension of the image."""
        return self._px_width

    @property
    def px_height(self):
        """The vertical pixel dimension of the image."""
        return self._px_height

    @property
    def horz_dpi(self):
        """Integer dots per inch for the width of this image.

        Defaults to 72 when not present in the file, as is often the case.
        """
        return self._horz_dpi

    @property
    def vert_dpi(self):
        """Integer dots per inch for the height of this image.

        Defaults to 72 when not present in the file, as is often the case.
        """
        return self._vert_dpi
