"""
Layer module.

This module implements the high-level layer API for psd-tools, providing
Pythonic interfaces for working with Photoshop layers. It defines the layer
type hierarchy and common operations.

Key classes:

- :py:class:`Layer`: Base class for all layer types
- :py:class:`GroupMixin`: Mixin for layers that contain children (groups, documents)
- :py:class:`Group`: Folder/group layer containing other layers
- :py:class:`PixelLayer`: Regular raster layer with pixel data
- :py:class:`TypeLayer`: Text layer with typography information
- :py:class:`ShapeLayer`: Vector shape layer
- :py:class:`SmartObjectLayer`: Embedded or linked smart object
- :py:class:`AdjustmentLayer`: Non-destructive adjustment (curves, levels, etc.)

Layer hierarchy:

Layers are organized in a tree structure where groups can contain child layers.
The :py:class:`GroupMixin` provides iteration, indexing, and search capabilities::

    # Iterate through all layers
    for layer in psd:
        print(layer.name)

    # Access by index
    first_layer = psd[0]

    # Check if layer is a specific type
    if layer.kind == 'pixel':
        pixels = layer.numpy()

Common layer properties:

- ``name``: Layer name
- ``visible``: Visibility flag
- ``opacity``: Opacity (0-255)
- ``blend_mode``: Blend mode enum
- ``bbox``: Bounding box (left, top, right, bottom)
- ``width``, ``height``: Dimensions
- ``kind``: Layer type string ('pixel', 'group', 'type', etc.)
- ``parent``: Parent layer or document

Layer operations:

- :py:meth:`~Layer.composite`: Render layer to PIL Image
- :py:meth:`~Layer.numpy`: Get pixel data as NumPy array
- :py:meth:`~Layer.topil`: Convert to PIL Image
- :py:meth:`~Layer.has_mask`: Check if layer has a mask
- :py:meth:`~Layer.has_clip_layers`: Check if layer has clipping mask

Example usage::

    from psd_tools import PSDImage

    psd = PSDImage.open('document.psd')

    # Access first layer
    layer = psd[0]

    # Modify layer properties
    layer.visible = False
    layer.opacity = 128
    layer.name = "New Name"

    # Get pixel data
    pixels = layer.numpy()  # NumPy array
    image = layer.topil()   # PIL Image

    # Work with groups
    for group in psd.descendants():
        if group.kind == 'group':
            print(f"Group: {group.name} with {len(group)} layers")

    # Composite specific layer
    rendered = layer.composite()
    rendered.save('layer.png')

Layer types are automatically determined from the underlying PSD structures
and exposed through the ``kind`` property for easy type checking.
"""

import logging
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Iterable,
    Iterator,
    Protocol,
    Sequence,
    TypeVar,
    cast,
    runtime_checkable,
)

from typing_extensions import Self

if TYPE_CHECKING:
    from psd_tools.api.typesetting import TypeSetting

import numpy as np
from PIL import Image, ImageChops

import psd_tools.psd.engine_data as engine_data
from psd_tools.api import numpy_io, pil_io
from psd_tools.color_convert import rgb_to_grayscale
from psd_tools.api.effects import Effects
from psd_tools.api.mask import Mask
from psd_tools.api.protocols import GroupMixinProtocol, LayerProtocol, PSDProtocol
from psd_tools.api.shape import Origination, Stroke, VectorMask
from psd_tools.api.smart_object import SmartObject
from psd_tools.constants import (
    BlendMode,
    ChannelID,
    Clipping,
    ColorMode,
    CompatibilityMode,
    Compression,
    ProtectedFlags,
    SectionDivider,
    SheetColorType,
    Tag,
    TextType,
)
from psd_tools.psd.descriptor import DescriptorBlock
from psd_tools.psd.layer_and_mask import (
    ChannelData,
    ChannelDataList,
    ChannelInfo,
    LayerRecord,
    MaskData,
    MaskFlags,
)
from psd_tools.psd.tagged_blocks import (
    ProtectedSetting,
    SectionDividerSetting,
    TaggedBlocks,
)
from psd_tools.terminology import Key

logger = logging.getLogger(__name__)


TGroupMixin = TypeVar("TGroupMixin", bound="GroupMixin")


class Layer(LayerProtocol):
    def __init__(
        self,
        parent: "GroupMixin",
        record: LayerRecord,
        channels: ChannelDataList,
    ):
        self._psd = parent._psd
        self._parent: "GroupMixinProtocol | None" = parent
        self._record = record
        self._channels = channels

    @property
    def name(self) -> str:
        """
        Layer name. Writable.

        :return: `str`
        """
        return self._record.tagged_blocks.get_data(
            Tag.UNICODE_LAYER_NAME, self._record.name
        )

    @name.setter
    def name(self, value: str) -> None:
        if len(value) >= 256:
            raise ValueError(
                "Layer name too long (%d characters, max 255): %s" % (len(value), value)
            )
        try:
            value.encode("macroman")
            self._record.name = value
        except UnicodeEncodeError:
            self._record.name = str("?")
        self._record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, value)

    @property
    def kind(self) -> str:
        """
        Kind of this layer, such as group, pixel, shape, type, smartobject,
        or psdimage. Class name without `layer` suffix.

        :return: `str`
        """
        return self.__class__.__name__.lower().replace("layer", "")

    @property
    def layer_id(self) -> int:
        """
        Layer ID.

        :return: int layer id. if the layer is not assigned an id, -1.
        """
        return self.tagged_blocks.get_data(Tag.LAYER_ID, -1)

    def _invalidate_bbox(self) -> None:
        """
        Invalidate this layer's _bbox and any parents recursively to the root.
        """
        if isinstance(self, (GroupMixin, ShapeLayer)):
            self._bbox: tuple[int, int, int, int] | None = None
        if isinstance(self.parent, (Group, Artboard)):
            self.parent._invalidate_bbox()

    @property
    def visible(self) -> bool:
        """
        Layer visibility. Doesn't take group visibility in account. Writable.

        :return: `bool`
        """
        return self._record.flags.visible

    @visible.setter
    def visible(self, value: bool) -> None:
        if self.visible != value and self._psd is not None:
            self._psd._mark_updated()
        self._invalidate_bbox()
        self._record.flags.visible = bool(value)

    def is_visible(self) -> bool:
        """
        Layer visibility. Takes group visibility in account.

        :return: `bool`
        """
        if not self.visible:
            return False
        elif self.parent is not None:
            return self.parent.is_visible()
        return True

    @property
    def opacity(self) -> int:
        """
        Opacity of this layer in [0, 255] range. Writable.

        :return: int
        """
        return self._record.opacity

    @opacity.setter
    def opacity(self, value: int) -> None:
        if not (0 <= value <= 255):
            raise ValueError(f"Opacity must be in range [0, 255], got {value}")
        if self.opacity != value and self._psd is not None:
            self._psd._mark_updated()
        self._record.opacity = int(value)

    @property
    def parent(self) -> GroupMixinProtocol | None:
        """Parent of this layer."""
        return self._parent  # type: ignore

    def next_sibling(self, visible: bool = False) -> Self | None:
        """Next sibling of this layer."""
        if self.parent is None:
            return None
        index = self.parent.index(self)  # type: ignore
        for i in range(index + 1, len(self.parent)):
            if not visible or self.parent[i].visible:
                return self.parent[i]  # type: ignore[return-value]
        return None

    def previous_sibling(self, visible: bool = False) -> Self | None:
        """Previous sibling of this layer."""
        if self.parent is None:
            return None
        index = self.parent.index(self)  # type: ignore
        for i in range(index - 1, -1, -1):
            if not visible or self.parent[i].visible:
                return self.parent[i]  # type: ignore[return-value]
        return None

    def is_group(self) -> bool:
        """
        Return True if the layer is a group.

        :return: `bool`
        """
        return False

    @property
    def blend_mode(self) -> BlendMode:
        """
        Blend mode of this layer. Writable.

        Example::

            from psd_tools.constants import BlendMode
            if layer.blend_mode == BlendMode.NORMAL:
                layer.blend_mode = BlendMode.SCREEN

        :return: :py:class:`~psd_tools.constants.BlendMode`.
        """
        return self._record.blend_mode

    @blend_mode.setter
    def blend_mode(self, value: bytes | str | BlendMode) -> None:
        if isinstance(value, str):
            value = value.encode("ascii")
        blend_mode = BlendMode(value)
        if self.blend_mode != blend_mode:
            self._psd._mark_updated()
        self._record.blend_mode = blend_mode

    @property
    def left(self) -> int:
        """
        Left coordinate. Writable.

        :return: int
        """
        return self._record.left

    @left.setter
    def left(self, value: int) -> None:
        if self.left != value:
            self._psd._mark_updated()
        self._invalidate_bbox()
        w = self.width
        self._record.left = int(value)
        self._record.right = int(value) + w

    @property
    def top(self) -> int:
        """
        Top coordinate. Writable.

        :return: int
        """
        return self._record.top

    @top.setter
    def top(self, value: int) -> None:
        if self.top != value and self._psd is not None:
            self._psd._mark_updated()
        self._invalidate_bbox()
        h = self.height
        self._record.top = int(value)
        self._record.bottom = int(value) + h

    @property
    def right(self) -> int:
        """
        Right coordinate.

        :return: int
        """
        return self._record.right

    @property
    def bottom(self) -> int:
        """
        Bottom coordinate.

        :return: int
        """
        return self._record.bottom

    @property
    def width(self) -> int:
        """
        Width of the layer.

        :return: int
        """
        return self.right - self.left

    @property
    def height(self) -> int:
        """
        Height of the layer.

        :return: int
        """
        return self.bottom - self.top

    @property
    def offset(self) -> tuple[int, int]:
        """
        (left, top) tuple. Writable.

        :return: `tuple`
        """
        return self.left, self.top

    @offset.setter
    def offset(self, value: tuple[int, int]) -> None:
        if len(value) != 2:
            raise ValueError(
                f"Offset must be a tuple of 2 integers, got {len(value)} elements"
            )
        self.left, self.top = tuple(int(x) for x in value)

    @property
    def size(self) -> tuple[int, int]:
        """
        (width, height) tuple.

        :return: `tuple`
        """
        return self.width, self.height

    @property
    def bbox(self) -> tuple[int, int, int, int]:
        """(left, top, right, bottom) tuple."""
        return self.left, self.top, self.right, self.bottom

    def has_pixels(self) -> bool:
        """
        Returns True if the layer has associated pixels. When this is True,
        `topil` method returns :py:class:`PIL.Image.Image`.

        :return: `bool`
        """
        return any(
            ci.id >= 0 and cd.data and len(cd.data) > 0
            for ci, cd in zip(self._record.channel_info, self._channels)
        )

    def has_mask(self) -> bool:
        """
        Returns True if the layer has a mask.

        :return: `bool`
        """
        return self._record.mask_data is not None

    @property
    def mask(self) -> Mask | None:
        """
        Returns mask associated with this layer.

        :return: :py:class:`~psd_tools.api.mask.Mask` or `None`
        """
        if not hasattr(self, "_mask"):
            self._mask = Mask(self) if self.has_mask() else None
        return self._mask

    def _make_mask_channel_data(
        self,
        image: Image.Image,
        compression: Compression,
    ) -> tuple[ChannelData, int, int]:
        """Return ``(channel_data, width, height)`` for a mask image.

        If the image has an alpha channel the alpha channel is used as the
        mask data; otherwise the image is converted to grayscale (``L`` mode).
        Layer masks in PSD are always 8-bit regardless of document depth.
        """
        if "A" in image.getbands():
            mask_pixels = image.getchannel("A")
        else:
            mask_pixels = image.convert("L")

        width, height = mask_pixels.size
        version = self._psd._record.header.version

        channel_data = ChannelData(compression)
        channel_data.set_data(mask_pixels.tobytes(), width, height, 8, version)
        return channel_data, width, height

    def create_mask(
        self,
        image: Image.Image,
        top: int | None = None,
        left: int | None = None,
        compression: Compression = Compression.RLE,
    ) -> Mask:
        """
        Create a pixel mask on this layer from a PIL Image.

        If the image has an alpha channel (e.g. RGBA, LA), the alpha channel
        is used as the mask data. Otherwise the image is converted to
        grayscale (``L`` mode). White (255) means fully unmasked, black (0)
        means fully masked.

        :param image: Source :py:class:`~PIL.Image.Image` for the mask.
        :param top: Top offset of the mask. Defaults to the layer's top.
        :param left: Left offset of the mask. Defaults to the layer's left.
        :param compression: Compression algorithm for the mask data.
        :return: The new :py:class:`~psd_tools.api.mask.Mask`.
        :raises ValueError: If the layer already has a mask.
        """
        if self.has_mask():
            raise ValueError("Layer already has a mask. Remove it first.")

        if top is None:
            top = self._record.top
        if left is None:
            left = self._record.left

        channel_data, width, height = self._make_mask_channel_data(image, compression)

        mask_data = MaskData(
            top=top,
            left=left,
            bottom=top + height,
            right=left + width,
            background_color=0,
            flags=MaskFlags(),
        )

        channel_info = ChannelInfo(
            id=ChannelID.USER_LAYER_MASK,
            length=channel_data._length,
        )

        self._record.mask_data = mask_data
        self._record.channel_info.append(channel_info)
        self._channels.append(channel_data)

        if hasattr(self, "_mask"):
            del self._mask
        self._psd._mark_updated()
        return self.mask  # type: ignore[return-value]

    def remove_mask(self) -> None:
        """
        Remove the pixel mask from this layer.

        :raises ValueError: If the layer does not have a mask.
        """
        if not self.has_mask():
            raise ValueError("Layer does not have a mask.")

        for i, ci in enumerate(self._record.channel_info):
            if ci.id == ChannelID.USER_LAYER_MASK:
                self._record.channel_info.pop(i)
                self._channels.pop(i)
                break

        self._record.mask_data = None

        if hasattr(self, "_mask"):
            del self._mask
        self._psd._mark_updated()

    def update_mask(
        self,
        image: Image.Image,
        top: int | None = None,
        left: int | None = None,
        compression: Compression = Compression.RLE,
    ) -> Mask:
        """
        Update the pixel mask of this layer with a new image.

        If the image has an alpha channel (e.g. RGBA, LA), the alpha channel
        is used as the mask data. Otherwise the image is converted to
        grayscale (``L`` mode). White (255) means fully unmasked, black (0)
        means fully masked.

        :param image: New source :py:class:`~PIL.Image.Image` for the mask.
        :param top: New top offset of the mask. Defaults to current mask top.
        :param left: New left offset of the mask. Defaults to current mask left.
        :param compression: Compression algorithm for the mask data.
        :return: The updated :py:class:`~psd_tools.api.mask.Mask`.
        :raises ValueError: If the layer does not have a mask.
        """
        if not self.has_mask():
            raise ValueError("Layer does not have a mask. Use create_mask() first.")

        channel_data, width, height = self._make_mask_channel_data(image, compression)

        mask_data = cast(MaskData, self._record.mask_data)
        new_top = top if top is not None else mask_data.top
        new_left = left if left is not None else mask_data.left
        mask_data.top = new_top
        mask_data.left = new_left
        mask_data.bottom = new_top + height
        mask_data.right = new_left + width

        for i, ci in enumerate(self._record.channel_info):
            if ci.id == ChannelID.USER_LAYER_MASK:
                self._channels[i] = channel_data
                self._record.channel_info[i].length = channel_data._length
                break

        if hasattr(self, "_mask"):
            del self._mask
        self._psd._mark_updated()
        return self.mask  # type: ignore[return-value]

    def has_vector_mask(self) -> bool:
        """
        Returns True if the layer has a vector mask.

        :return: `bool`
        """
        return any(
            key in self.tagged_blocks
            for key in (Tag.VECTOR_MASK_SETTING1, Tag.VECTOR_MASK_SETTING2)
        )

    @property
    def vector_mask(self) -> VectorMask | None:
        """
        Returns vector mask associated with this layer.

        :return: :py:class:`~psd_tools.api.shape.VectorMask` or `None`
        """
        if not hasattr(self, "_vector_mask"):
            self._vector_mask = None
            blocks = self.tagged_blocks
            for key in (Tag.VECTOR_MASK_SETTING1, Tag.VECTOR_MASK_SETTING2):
                if key in blocks:
                    self._vector_mask = VectorMask(blocks.get_data(key))
                    break
        return self._vector_mask

    def has_origination(self) -> bool:
        """
        Returns True if the layer has live shape properties.

        :return: `bool`
        """
        if self.origination:
            return True
        return False

    @property
    def origination(self) -> list[Origination]:
        """
        Property for a list of live shapes or a line.

        Some of the vector masks have associated live shape properties, that
        are Photoshop feature to handle primitive shapes such as a rectangle,
        an ellipse, or a line. Vector masks without live shape properties are
        plain path objects.

        See :py:mod:`psd_tools.api.shape`.

        :return: List of :py:class:`~psd_tools.api.shape.Invalidated`,
            :py:class:`~psd_tools.api.shape.Rectangle`,
            :py:class:`~psd_tools.api.shape.RoundedRectangle`,
            :py:class:`~psd_tools.api.shape.Ellipse`, or
            :py:class:`~psd_tools.api.shape.Line`.
        """
        if not hasattr(self, "_origination"):
            data = self.tagged_blocks.get_data(Tag.VECTOR_ORIGINATION_DATA, {})
            self._origination: list[Origination] = [
                Origination.create(x)
                for x in data.get(b"keyDescriptorList", [])
                if not data.get(b"keyShapeInvalidated")
            ]
        return self._origination

    def has_stroke(self) -> bool:
        """Returns True if the shape has a stroke."""
        return Tag.VECTOR_STROKE_DATA in self.tagged_blocks

    @property
    def stroke(self) -> Stroke | None:
        """Property for strokes."""
        if not hasattr(self, "_stroke"):
            self._stroke = None
            stroke = self.tagged_blocks.get_data(Tag.VECTOR_STROKE_DATA)
            if stroke:
                self._stroke = Stroke(stroke)
        return self._stroke

    def lock(self, lock_flags: int = ProtectedFlags.COMPLETE) -> None:
        """
        Locks a layer accordind to the combination of flags.

        :param lockflags: An integer representing the locking state

        Example using the constants of ProtectedFlags and bitwise or operation
        to lock both pixels and positions::

            layer.lock(ProtectedFlags.COMPOSITE | ProtectedFlags.POSITION)
        """

        locks = self.locks

        if locks is None:
            locks = ProtectedSetting(0)
            self.tagged_blocks.set_data(Tag.PROTECTED_SETTING, locks)

        locks.lock(lock_flags)

    def unlock(self) -> None:
        self.lock(0)

    @property
    def locks(self) -> ProtectedSetting | None:
        protected_settings_block = self.tagged_blocks.get(Tag.PROTECTED_SETTING)

        if protected_settings_block is not None:
            return protected_settings_block.data

        return None

    def topil(
        self, channel: int | None = None, apply_icc: bool = True
    ) -> Image.Image | None:
        """
        Get PIL Image of the layer.

        :param channel: Which channel to return; e.g., 0 for 'R' channel in RGB
            image. See :py:class:`~psd_tools.constants.ChannelID`. When `None`,
            the method returns all the channels supported by PIL modes.
        :param apply_icc: Whether to apply ICC profile conversion to sRGB.
        :return: :py:class:`PIL.Image.Image`, or `None` if the layer has no pixels.

        Example::

            from psd_tools.constants import ChannelID

            image = layer.topil()
            red = layer.topil(ChannelID.CHANNEL_0)
            alpha = layer.topil(ChannelID.TRANSPARENCY_MASK)

        .. note:: Not all of the PSD image modes are supported in
            :py:class:`PIL.Image.Image`. For example, 'CMYK' mode cannot include
            alpha channel in PIL. In this case, topil drops alpha channel.
        """
        return pil_io.convert_layer_to_pil(self, channel, apply_icc)

    def numpy(
        self, channel: str | None = None, real_mask: bool = True
    ) -> np.ndarray | None:
        """
        Get NumPy array of the layer.

        :param channel: Which channel to return, can be 'color',
            'shape', 'alpha', or 'mask'. Default is 'color+alpha'.
        :return: :py:class:`numpy.ndarray` or None if there is no pixel.
        """
        return numpy_io.get_array(self, channel, real_mask=real_mask)

    def composite(
        self,
        viewport: tuple[int, int, int, int] | None = None,
        force: bool = False,
        color: float | tuple[float, ...] | np.ndarray = 1.0,
        alpha: float | np.ndarray = 0.0,
        layer_filter: Callable | None = None,
        apply_icc: bool = True,
    ) -> Image.Image | None:
        """
        Composite layer and masks (mask, vector mask, and clipping layers).

        :param viewport: Viewport bounding box specified by (x1, y1, x2, y2)
            tuple. Default is the layer's bbox.
        :param force: Boolean flag to force vector drawing.
        :param color: Backdrop color specified by scalar or tuple of scalar.
            The color value should be in [0.0, 1.0]. For example, (1., 0., 0.)
            specifies red in RGB color mode.
        :param alpha: Backdrop alpha in [0.0, 1.0].
        :param layer_filter: Callable that takes a layer as argument and
            returns whether if the layer is composited. Default is
            :py:func:`~psd_tools.api.layers.PixelLayer.is_visible`.
        :return: :py:class:`PIL.Image.Image` or `None`.
        """
        from psd_tools.composite import composite_pil  # noqa: PLC0415

        if self._psd is not None and self._psd.is_updated():
            force = True

        return composite_pil(
            self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc
        )

    def has_clip_layers(self, visible: bool = False) -> bool:
        """
        Returns True if the layer has associated clipping.

        :param visible: If True, check for visible clipping layers.
        :return: `bool`
        """
        if visible:
            return any(layer.is_visible() for layer in self.clip_layers)
        return len(self.clip_layers) > 0

    @property
    def clip_layers(self) -> list[Self]:
        """
        Clip layers associated with this layer.

        :return: list of layers
        """
        if self.clipping:
            return []

        # Look for clipping layers in the parent scope.
        parent: GroupMixin = self.parent or self._psd  # type: ignore
        index = parent.index(self)

        # TODO: Cache the result and invalidate when needed.
        _clip_layers = []
        for layer in parent[index + 1 :]:  # type: ignore
            if layer.clipping:
                if (
                    isinstance(layer, GroupMixin)
                    and layer._psd.compatibility_mode == CompatibilityMode.PHOTOSHOP
                ):
                    # In Photoshop, clipping groups are not supported.
                    break
                _clip_layers.append(layer)
            else:
                break

        return _clip_layers

    @property
    def clipping(self) -> bool:
        """
        Clipping flag for this layer. Writable.

        :return: `bool`
        """
        return self._record.clipping == Clipping.NON_BASE

    @clipping.setter
    def clipping(self, value: bool) -> None:
        clipping = Clipping.NON_BASE if value else Clipping.BASE
        if self._record.clipping != clipping and self._psd is not None:
            self._psd._mark_updated()
        self._record.clipping = clipping
        self._invalidate_bbox()

    @property
    def clipping_layer(self) -> bool:
        """Deprecated. Use clipping property instead."""
        logger.warning(
            "clipping_layer property is deprecated. Use clipping property instead."
        )
        return self.clipping

    @clipping_layer.setter
    def clipping_layer(self, value: bool) -> None:
        """Deprecated. Use clipping property instead."""
        logger.warning(
            "clipping_layer property is deprecated. Use clipping property instead."
        )
        self.clipping = value

    def has_effects(self, enabled: bool = True, name: str | None = None) -> bool:
        """
        Returns True if the layer has effects.

        :param enabled: If True, check for enabled effects.
        :param name: If given, check for specific effect type.
        :return: `bool`
        """
        has_effect_tag = any(
            tag in self.tagged_blocks
            for tag in (
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO,
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V0,
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V1,
            )
        )
        # No effects tag.
        if not has_effect_tag:
            return False

        # Global enable flag check.
        if enabled and not self.effects.enabled:
            return False

        # No specific effect type, check for any effect.
        if name is None:
            if enabled:
                return any(effect.enabled for effect in self.effects)
            return True

        # Check for specific effect type and enabled state.
        return any(self.effects.find(name, enabled))

    @property
    def effects(self) -> Effects:
        """
        Layer effects.

        :return: :py:class:`~psd_tools.api.effects.Effects`
        """
        if not hasattr(self, "_effects"):
            self._effects = Effects(self)
        return self._effects

    @property
    def tagged_blocks(self) -> TaggedBlocks:
        """
        Layer tagged blocks that is a dict-like container of settings.

        See :py:class:`psd_tools.constants.Tag` for available
        keys.

        :return: :py:class:`~psd_tools.psd.tagged_blocks.TaggedBlocks`.

        Example::

            from psd_tools.constants import Tag
            metadata = layer.tagged_blocks.get_data(Tag.METADATA_SETTING)
        """
        return self._record.tagged_blocks

    @property
    def fill_opacity(self) -> int:
        """
        Fill opacity of this layer in [0, 255] range. Writable.

        :return: int
        """
        return self.tagged_blocks.get_data(Tag.BLEND_FILL_OPACITY, 255)

    @fill_opacity.setter
    def fill_opacity(self, value: int) -> None:
        if value < 0 or value > 255:
            raise ValueError("Fill opacity must be between 0 and 255.")
        if self.fill_opacity != value and self._psd is not None:
            self._psd._mark_updated()
        self.tagged_blocks.set_data(Tag.BLEND_FILL_OPACITY, int(value))

    @property
    def reference_point(self) -> tuple[float, float]:
        """
        Reference point of this layer as (x, y) tuple in the canvas coordinates. Writable.

        Reference point is used for transformations such as rotation and scaling.

        :return: (x, y) tuple
        """
        return tuple(self.tagged_blocks.get_data(Tag.REFERENCE_POINT, (0.0, 0.0)))

    @reference_point.setter
    def reference_point(self, value: Sequence[float]) -> None:
        if len(value) != 2:
            raise ValueError("Reference point must be a sequence of two floats.")
        if self.reference_point != value and self._psd is not None:
            self._psd._mark_updated()
        self.tagged_blocks.set_data(
            Tag.REFERENCE_POINT, [float(value[0]), float(value[1])]
        )

    @property
    def sheet_color(self) -> SheetColorType:
        """
        Color label of this layer in the Photoshop layers panel. Writable.

        :return: :py:class:`~psd_tools.constants.SheetColorType`
        """
        return self.tagged_blocks.get_data(
            Tag.SHEET_COLOR_SETTING, SheetColorType.NO_COLOR
        )

    @sheet_color.setter
    def sheet_color(self, value: SheetColorType) -> None:
        value = SheetColorType(value)
        if self.sheet_color != value and self._psd is not None:
            self._psd._mark_updated()
        self.tagged_blocks.set_data(Tag.SHEET_COLOR_SETTING, value)

    def __repr__(self) -> str:
        has_size = self.width > 0 and self.height > 0
        return "%s(%r%s%s%s%s%s)" % (
            self.__class__.__name__,
            self.name,
            " size=%dx%d" % (self.width, self.height) if has_size else "",
            " invisible" if not self.visible else "",
            " clip" if self.clipping else "",
            " mask" if self.has_mask() else "",
            " effects" if self.has_effects() else "",
        )

    # Structure operations
    def delete_layer(self) -> Self:
        """
        Deprecated: Use layer.parent.remove(layer) instead.
        """
        if self.parent is not None and isinstance(self.parent, GroupMixin):
            self.parent.remove(self)
        return self

    def move_to_group(self, group: "GroupMixin") -> Self:
        """
        Deprecated: Use group.append(layer) instead.

        :param group: The group the current layer will be moved into.
        """
        group.append(self)
        return self

    def move_up(self, offset: int = 1) -> Self:
        """
        Moves the layer up a certain offset within the group the layer is in.

        :param offset: The number of positions to move the layer up (can be negative).
        :raises ValueError: If layer has no parent or parent is not a group
        :raises IndexError: If the new index is out of bounds
        :return: self
        """
        if self.parent is None:
            raise ValueError(f"Cannot move layer {self} without a parent")
        if not isinstance(self.parent, GroupMixin):
            raise TypeError(
                f"Parent must be a GroupMixin, got {type(self.parent).__name__}"
            )

        newindex = self.parent.index(self) + offset
        if newindex < 0:
            raise IndexError("Cannot move layer beyond the bottom of the group")
        elif newindex >= len(self.parent):
            raise IndexError("Cannot move layer beyond the top of the group")
        parent = self.parent
        parent.remove(self)
        parent.insert(newindex, self)
        return self

    def move_down(self, offset: int = 1) -> Self:
        """
        Moves the layer down a certain offset within the group the layer is in.

        :param offset: The number of positions to move the layer down (can be negative).
        :raises ValueError: If layer has no parent or parent is not a group
        :raises IndexError: If the new index is out of bounds
        :return: self
        """
        return self.move_up(-1 * offset)


@runtime_checkable
class GroupMixin(GroupMixinProtocol, Protocol):
    _psd: PSDProtocol
    _bbox: tuple[int, int, int, int] | None = None
    _layers: list[Layer]

    # Note: left, top, right, bottom properties are inherited from Layer
    # and computed via bbox. Groups compute bbox from children, not from _record.

    @property
    def bbox(self) -> tuple[int, int, int, int]:
        """(left, top, right, bottom) tuple computed from visible, non-clipping children."""
        if self._bbox is None:
            self._bbox = Group.extract_bbox(self)
        return self._bbox

    def __len__(self) -> int:
        return self._layers.__len__()

    def __iter__(self) -> Iterator[Layer]:
        return self._layers.__iter__()

    def __reversed__(self) -> Iterator[Layer]:
        return self._layers.__reversed__()

    def __contains__(self, item: object) -> bool:
        return item in self._layers

    def __getitem__(self, key: int) -> Layer:
        return self._layers.__getitem__(key)

    def __setitem__(self, key: int, value: Layer) -> None:
        self.insert(key, value)

    def __delitem__(self, key: int) -> None:
        self.remove(self._layers[key])

    def append(self, layer: Layer) -> None:
        """
        Add a layer to the end (top) of the group.

        This operation rewrites the internal references of the layer.
        Adding the same layer will not create a duplicate.

        :param layer: The layer to add.
        :raises TypeError: If the provided object is not a Layer instance.
        :raises ValueError: If attempting to add a group to itself.
        """
        self.extend([layer])

    def extend(self, layers: Iterable[Layer]) -> None:
        """
        Add a list of layers to the end (top) of the group.

        This operation rewrites the internal references of the layers.
        Adding the same layer will not create a duplicate.

        :param layers: The layers to add.
        :raises TypeError: If the provided object is not a Layer instance.
        :raises ValueError: If attempting to add a group to itself.
        """
        self._check_insertion(layers)
        # Remove parent's reference to the layers.
        for layer in layers:
            # NOTE: New or removed layers may not be in the parent container.
            if isinstance(layer.parent, GroupMixin) and layer in layer.parent:
                layer.parent._layers.remove(layer)  # Skip checks for performance
        self._layers.extend(layers)
        self._update_children()
        self._psd._update_record()

    def insert(self, index: int, layer: Layer) -> None:
        """
        Insert the given layer at the specified index.

        This operation rewrites the internal references of the layer.

        :param index: The index to insert the layer at.
        :param layer: The layer to insert.
        :raises TypeError: If the provided object is not a Layer instance.
        :raises ValueError: If attempting to add a group to itself.
        """
        self._check_insertion([layer])
        # Remove parent's reference to the layer.
        if isinstance(layer.parent, GroupMixin) and layer in layer.parent:
            layer.parent._layers.remove(layer)  # Skip checks for performance
        self._layers.insert(index, layer)
        self._update_children()
        self._psd._update_record()

    def remove(self, layer: Layer) -> Self:
        """
        Removes the specified layer from the group.

        This operation rewrites the internal references of the layer.

        :param layer: The layer to remove.
        :raises ValueError: If the layer is not found in the group.
        :return: self
        """
        if layer not in self:
            raise ValueError(f"Layer {layer} not found in group {self}")
        self._layers.remove(layer)
        layer._parent = None
        self._psd._update_record()
        return self

    def pop(self, index: int = -1) -> Layer:
        """
        Removes the specified layer from the list and returns it.

        This operation rewrites the internal references of the layer.

        :param index: The index of the layer to remove. Default is -1 (the last layer).
        :raises IndexError: If the index is out of range.
        :return: The removed layer.
        """
        layer = self[index]
        self.remove(layer)
        return layer

    def clear(self) -> None:
        """
        Clears the group.

        This operation rewrites the internal references of the layers.

        :return: None
        """
        for layer in self._layers:
            layer._parent = None
        self._layers.clear()
        self._psd._update_record()

    def index(self, layer: Layer) -> int:
        """
        Returns the index of the specified layer in the group.

        :param layer: The layer to find.
        """
        return self._layers.index(layer)

    def count(self, layer: Layer) -> int:
        """
        Counts the number of occurrences of a layer in the group.

        :param layer: The layer to count.
        """
        return self._layers.count(layer)

    def _check_insertion(self, layers: Iterable[Layer]) -> None:
        """Check that the given layers can be added to this group.

        :raises ValueError: If attempting to add a group to itself or create a reference loop
        :raises TypeError: If the provided object is not a Layer instance
        """
        for layer in layers:
            if not isinstance(layer, Layer):
                raise TypeError(f"Expected Layer instance, got {type(layer).__name__}")
            if layer is self:
                raise ValueError(f"Cannot add the group {self} to itself")
            if isinstance(layer, GroupMixin):
                if self in list(layer.descendants()):
                    raise ValueError(
                        "This operation would create a reference loop "
                        f"within the group between {self} and {layer}"
                    )

    def _update_children(self) -> None:
        """Update children's _psd and _parent references."""
        for layer in self:
            # Update PSD reference if needed
            if layer._psd != self._psd:
                if isinstance(layer, PixelLayer):
                    layer._convert_mode(self)
                layer._psd._copy_patterns(self._psd)  # TODO: optimize
                layer._psd = self._psd
            # Update parent reference
            layer._parent = self
            if isinstance(layer, GroupMixin):
                layer._update_children()

    def is_visible(self) -> bool:
        """Returns visibility of the element."""
        return Layer.is_visible(self)  # type: ignore

    def is_group(self) -> bool:
        """Return True if this is a group."""
        return True

    def descendants(self, include_clip: bool = True) -> Iterator[Layer]:
        """
        Return a generator to iterate over all descendant layers.

        :param include_clip: Whether to include clipping layers. Default is True.

        Example::

            # Iterate over all layers
            for layer in psd.descendants():
                print(layer)

            # Iterate over all layers in reverse order
            for layer in reversed(list(psd.descendants())):
                print(layer)
        """
        for layer in self:
            if not include_clip and hasattr(layer, "clipping") and layer.clipping:
                continue
            yield layer
            if isinstance(layer, GroupMixin):
                yield from layer.descendants(include_clip=include_clip)

    def find(self, name: str) -> Layer | None:
        """
        Returns the first layer found for the given layer name

        :param name:
        """

        for layer in self.findall(name):
            return layer
        return None

    def findall(self, name: str) -> Iterator[Layer]:
        """
        Return a generator to iterate over all layers with the given name.

        :param name:
        """

        for layer in self.descendants():
            if layer.name == name:
                yield layer


class Group(GroupMixin, Layer):
    """
    Group of layers.

    Example::

        group = psd[1]
        for layer in group:
            if layer.kind == 'pixel':
                print(layer.name)
    """

    def __init__(
        self,
        parent: GroupMixin,
        record: LayerRecord,
        channels: ChannelDataList,
    ):
        self._layers = []
        self._bounding_record: LayerRecord | None = None
        self._bounding_channels: ChannelDataList | None = None
        Layer.__init__(self, parent, record, channels)

    @property
    def _setting(self) -> SectionDividerSetting | None:
        """Low-level section divider setting."""
        # Can be None.
        return self.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING)

    @property
    def blend_mode(self) -> BlendMode:
        """Blend mode of this layer. Writable."""
        setting = self._setting
        # Use the blend mode from the section divider setting if present.
        if setting is not None and setting.blend_mode is not None:
            return setting.blend_mode
        return super(Group, self).blend_mode

    @blend_mode.setter
    def blend_mode(self, value: str | bytes | BlendMode) -> None:
        _value = BlendMode(value.encode("ascii") if isinstance(value, str) else value)
        if self.blend_mode != _value and self._psd is not None:
            self._psd._mark_updated()
        if _value == BlendMode.PASS_THROUGH:
            self._record.blend_mode = BlendMode.NORMAL
        else:
            self._record.blend_mode = _value
        setting = self._setting
        if setting is not None:
            setting.blend_mode = _value

    # Override Layer's writable position properties with read-only computed ones
    @property
    def left(self) -> int:
        """Left coordinate (computed from children, read-only)."""
        return self.bbox[0]

    @left.setter
    def left(self, value: int) -> None:
        raise NotImplementedError(
            "Cannot set position on Group directly. Position is computed from children."
        )

    @property
    def top(self) -> int:
        """Top coordinate (computed from children, read-only)."""
        return self.bbox[1]

    @top.setter
    def top(self, value: int) -> None:
        raise NotImplementedError(
            "Cannot set position on Group directly. Position is computed from children."
        )

    @property
    def right(self) -> int:
        """Right coordinate (computed from children, read-only)."""
        return self.bbox[2]

    @property
    def bottom(self) -> int:
        """Bottom coordinate (computed from children, read-only)."""
        return self.bbox[3]

    @property
    def clipping(self) -> bool:
        """
        Clipping flag for this layer. Writable.

        :return: `bool`
        """
        if self._psd.compatibility_mode == CompatibilityMode.PHOTOSHOP:
            # In Photoshop, clipping groups are not supported.
            return False
        return self._record.clipping == Clipping.NON_BASE

    @clipping.setter
    def clipping(self, value: bool) -> None:
        if self._psd.compatibility_mode == CompatibilityMode.PHOTOSHOP:
            logger.warning(
                "Cannot set clipping flag on groups in Photoshop compatibility mode."
            )
            return
        clipping = Clipping.NON_BASE if value else Clipping.BASE
        if self._record.clipping != clipping:
            self._psd._mark_updated()
        self._record.clipping = clipping
        self._invalidate_bbox()

    @property
    def open_folder(self) -> bool:
        """
        Returns True if the group is an open folder.

        :return: `bool`
        """
        if self._setting is None:
            raise ValueError("Section divider setting is missing.")
        return self._setting.kind == SectionDivider.OPEN_FOLDER

    @open_folder.setter
    def open_folder(self, value: bool) -> None:
        """
        Sets whether the group is an open folder.

        :param value: `bool`
        """
        if self._setting is None:
            raise ValueError("Section divider setting is missing.")
        kind = SectionDivider.OPEN_FOLDER if value else SectionDivider.CLOSED_FOLDER
        current_kind = self._setting.kind
        if current_kind != kind:
            self._setting.kind = kind
            # This change does not affect pixel data, so no need to mark PSD as updated.

    def is_group(self) -> bool:
        """
        Return True if the layer is a group.

        :return: `bool`
        """
        return True

    def composite(
        self,
        viewport: tuple[int, int, int, int] | None = None,
        force: bool = False,
        color: float | tuple[float, ...] | np.ndarray = 1.0,
        alpha: float | np.ndarray = 0.0,
        layer_filter: Callable | None = None,
        apply_icc: bool = True,
    ) -> Image.Image | None:
        """
        Composite layer and masks (mask, vector mask, and clipping layers).

        :param viewport: Viewport bounding box specified by (x1, y1, x2, y2)
            tuple. Default is the layer's bbox.
        :param force: Boolean flag to force vector drawing.
        :param color: Backdrop color specified by scalar or tuple of scalar.
            The color value should be in [0.0, 1.0]. For example, (1., 0., 0.)
            specifies red in RGB color mode.
        :param alpha: Backdrop alpha in [0.0, 1.0].
        :param layer_filter: Callable that takes a layer as argument and
            returns whether if the layer is composited. Default is
            :py:func:`~psd_tools.api.layers.PixelLayer.is_visible`.
        :return: :py:class:`PIL.Image.Image`.
        """
        from psd_tools.composite import composite_pil  # noqa: PLC0415

        return composite_pil(
            self,
            color,
            alpha,
            viewport,
            layer_filter,
            force,
            as_layer=True,
            apply_icc=apply_icc,
        )

    @staticmethod
    def extract_bbox(
        layers: Sequence[Layer] | GroupMixin,
        include_invisible: bool = False,
        include_clipping: bool = False,
    ) -> tuple[int, int, int, int]:
        """
        Returns a bounding box for ``layers`` or (0, 0, 0, 0) if the layers
        have no bounding box.

        :param layers: sequence of layers or a group.
        :param include_invisible: include invisible layers in calculation.
        :param include_clipping: include clipping layers in calculation.
            Defaults to False to match visible pixel bounds.
        :return: tuple of four int
        """

        def _get_bbox(layer: Layer, **kwargs: Any) -> tuple[int, int, int, int]:
            if layer.is_group() and isinstance(layer, GroupMixin):
                return Group.extract_bbox(layer, **kwargs)
            else:
                return layer.bbox

        bboxes = [
            _get_bbox(
                layer,
                include_invisible=include_invisible,
                include_clipping=include_clipping,
            )
            for layer in layers
            if (include_invisible or layer.is_visible())
            and (include_clipping or not layer.clipping)
        ]
        bboxes = [bbox for bbox in bboxes if bbox != (0, 0, 0, 0)]
        if len(bboxes) == 0:  # Empty bounding box.
            logger.info("No bounding box could be extracted from the given layers.")
            return (0, 0, 0, 0)
        lefts, tops, rights, bottoms = zip(*bboxes)
        return (min(lefts), min(tops), max(rights), max(bottoms))

    def _set_bounding_records(
        self, _bounding_record: LayerRecord, _bounding_channels: ChannelDataList
    ) -> None:
        # Attributes that store the record for the folder divider.
        # Used when updating the record so that we don't need to recompute
        # Them from the ending layer
        self._bounding_record = _bounding_record
        self._bounding_channels = _bounding_channels
        return

    @classmethod
    def new(
        cls,
        parent: GroupMixin,
        name: str = "Group",
        open_folder: bool = True,
    ) -> Self:
        """
        Create a new Group object with minimal records and data channels and metadata
        to properly include the group in the PSD file.

        :param name: The display name of the group. Default to "Group".
        :param open_folder: Boolean defining whether the folder will be open or closed
            in photoshop. Default to True.
        :param parent: Optional parent folder to move the newly created group into.

        :return: A :py:class:`~psd_tools.api.layers.Group` object
        :raises ValueError: If parent is None
        """
        if parent is None:
            raise ValueError("Parent cannot be None")

        # Create the layer record for the group.
        record = LayerRecord(top=0, left=0, bottom=0, right=0, name=name)
        record.tagged_blocks = TaggedBlocks()
        kind = (
            SectionDivider.OPEN_FOLDER if open_folder else SectionDivider.CLOSED_FOLDER
        )
        record.tagged_blocks.set_data(Tag.SECTION_DIVIDER_SETTING, kind=kind)
        record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, name)
        # TODO: Check the number of channels needed
        record.channel_info = [ChannelInfo(id=i - 1, length=2) for i in range(4)]

        # Create the bounding layer record.
        bounding_record = LayerRecord(
            top=0, left=0, bottom=0, right=0, name="</Layer group>"
        )
        bounding_record.tagged_blocks = TaggedBlocks()
        bounding_record.tagged_blocks.set_data(
            Tag.SECTION_DIVIDER_SETTING, SectionDivider.BOUNDING_SECTION_DIVIDER
        )
        bounding_record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, "</Layer group>")
        bounding_record.channel_info = [
            ChannelInfo(id=i - 1, length=2) for i in range(4)
        ]

        channels = ChannelDataList()
        for _ in range(4):  # TODO: Check the number of channels needed
            channels.append(ChannelData(compression=Compression.RAW, data=b""))
        bounding_channels = channels

        group = cls(parent, record, channels)
        group._set_bounding_records(bounding_record, bounding_channels)
        parent.append(group)
        return group

    @classmethod
    def group_layers(
        cls,
        parent: GroupMixin,
        layers: Sequence[Layer],
        name: str = "Group",
        open_folder: bool = True,
    ) -> Self:
        """
        Deprecated: Use ``psdimage.create_group(layer_list, name)`` instead.

        :param parent: The parent group to add the newly created Group object into.
        :param layers: The layers to group. Can by any subclass of
            :py:class:`~psd_tools.api.layers.Layer`
        :param name: The display name of the group. Default to "Group".
        :param open_folder: Boolean defining whether the folder will be open or closed in
            photoshop. Default to True.

        :return: A :py:class:`~psd_tools.api.layers.Group`
        :raises ValueError: If layers is empty
        """
        if len(layers) == 0:
            raise ValueError("Cannot create a group from an empty list of layers")
        group = cls.new(parent, name, open_folder)
        group.extend(layers)
        return group


class Artboard(Group):
    """
    Artboard is a special kind of group that has a pre-defined viewbox.
    """

    @classmethod
    def _move(kls, group: Group) -> "Artboard":
        """Converts a Group into an Artboard, updating all references as needed.

        :raises ValueError: If group has no parent
        """
        if group.parent is None:
            raise ValueError("Cannot convert a group without a parent to an Artboard")
        self = kls(group.parent, group._record, group._channels)  # type: ignore
        self._layers = group._layers
        if group._bounding_record is not None and group._bounding_channels is not None:
            self._set_bounding_records(group._bounding_record, group._bounding_channels)
        for layer in self._layers:
            layer._parent = self
        if self.parent is None:
            raise ValueError("Artboard parent is None after conversion")
        for index in range(len(self.parent)):
            if group == self.parent[index]:
                if not isinstance(self.parent, GroupMixin):
                    raise TypeError(
                        f"Parent must be GroupMixin, got {type(self.parent).__name__}"
                    )
                self.parent._layers[index] = self
        return self

    def composite(
        self,
        viewport: tuple[int, int, int, int] | None = None,
        force: bool = False,
        color: float | tuple[float, ...] | np.ndarray | None = None,
        alpha: float | np.ndarray | None = None,
        layer_filter: Callable | None = None,
        apply_icc: bool = True,
    ) -> Image.Image | None:
        """
        Composite layer, automatically applying the artboard's background color.

        When either ``color`` or ``alpha`` is not explicitly provided, reads
        the artboard background from the ARTBOARD_DATA tagged block
        (``artboardBackgroundType`` and ``Clr`` descriptor) and uses the
        missing value or values as the compositing backdrop.
        """
        if color is None or alpha is None:
            artboard_color, artboard_alpha = self._artboard_background_defaults()
            if color is None:
                color = artboard_color
            if alpha is None:
                alpha = artboard_alpha
        # NOTE: The lower-level psd_tools.composite.composite(artboard) function
        # does not inject artboard background.
        return super().composite(
            viewport=viewport,
            force=force,
            color=color,
            alpha=alpha,
            layer_filter=layer_filter,
            apply_icc=apply_icc,
        )

    def _artboard_background_defaults(
        self,
    ) -> tuple[float | tuple[float, ...], float]:
        """Return (color, alpha) based on artboard background settings.

        Reads artboardBackgroundType from ARTBOARD_DATA1/2/3 tagged blocks
        (last one found wins, matching the bbox() iteration pattern).

        Background types (Photoshop scripting convention):
          1 = Transparent, 2 = White, 3 = Black, 4 = Custom color

        Falls back to (1.0, 0.0) — the Group default — on any missing or
        unsupported data.
        """
        data = None
        for key in (Tag.ARTBOARD_DATA1, Tag.ARTBOARD_DATA2, Tag.ARTBOARD_DATA3):
            if key in self.tagged_blocks:
                data = self.tagged_blocks.get_data(key)

        if data is None:
            return 1.0, 0.0

        bg_type = data.get(b"artboardBackgroundType")
        if bg_type is None:
            return 1.0, 0.0

        psd = self._psd
        color_mode = psd.color_mode if psd is not None else ColorMode.RGB

        bg_type = int(bg_type)
        if bg_type == 1:  # Transparent
            return 1.0, 0.0
        elif bg_type in (2, 3):  # White or Black — color-mode aware
            white = bg_type == 2
            if color_mode in (
                ColorMode.RGB,
                ColorMode.GRAYSCALE,
                ColorMode.BITMAP,
                ColorMode.DUOTONE,
            ):
                return (1.0 if white else 0.0), 1.0
            elif color_mode == ColorMode.LAB:
                # LAB: L in [0,1]; a and b channels are neutral at 0.5
                return (1.0 if white else 0.0, 0.5, 0.5), 1.0
            elif color_mode == ColorMode.CMYK:
                # CMYK: white = no ink (0,0,0,0); black = full K (0,0,0,1)
                return ((0.0, 0.0, 0.0, 0.0) if white else (0.0, 0.0, 0.0, 1.0)), 1.0
            else:
                logger.debug(
                    "Artboard background color not applied: unsupported color mode %s",
                    color_mode,
                )
                return 1.0, 0.0
        elif bg_type == 4:  # Custom color (Clr descriptor, RGB 0-255)
            clr = data.get(Key.Color)
            if clr is None:
                return 1.0, 0.0
            rd = clr.get(Key.Red)
            gn = clr.get(Key.Green)
            bl = clr.get(Key.Blue)
            if rd is None or gn is None or bl is None:
                return 1.0, 0.0
            r, g, b_val = float(rd) / 255.0, float(gn) / 255.0, float(bl) / 255.0
            if color_mode == ColorMode.RGB:
                return (r, g, b_val), 1.0
            elif color_mode in (
                ColorMode.GRAYSCALE,
                ColorMode.BITMAP,
                ColorMode.DUOTONE,
            ):
                lum = rgb_to_grayscale(r, g, b_val)
                return lum, 1.0
            else:
                # NOTE: The Clr descriptor is always RGB regardless of document
                # color mode. Conversion to CMYK, LAB, etc. is non-trivial and
                # not implemented; those modes fall back to the transparent default.
                logger.debug(
                    "Artboard background color not applied: unsupported color mode %s",
                    color_mode,
                )
                return 1.0, 0.0

        return 1.0, 0.0  # Unknown background type

    @property
    def left(self) -> int:
        return self.bbox[0]

    @left.setter
    def left(self, value: int) -> None:
        raise NotImplementedError("Artboard left position is not writable yet.")

    @property
    def top(self) -> int:
        return self.bbox[1]

    @top.setter
    def top(self, value: int) -> None:
        raise NotImplementedError("Artboard top position is not writable yet.")

    @property
    def right(self) -> int:
        return self.bbox[2]

    @property
    def bottom(self) -> int:
        return self.bbox[3]

    @property
    def bbox(self) -> tuple[int, int, int, int]:
        """(left, top, right, bottom) tuple."""
        if self._bbox is None:
            data = None
            for key in (Tag.ARTBOARD_DATA1, Tag.ARTBOARD_DATA2, Tag.ARTBOARD_DATA3):
                if key in self.tagged_blocks:
                    data = self.tagged_blocks.get_data(key)
            if data is None:
                raise ValueError("Artboard data not found in tagged blocks")
            rect = data.get(b"artboardRect")
            self._bbox = (
                int(rect.get(b"Left")),
                int(rect.get(b"Top ")),
                int(rect.get(b"Rght")),
                int(rect.get(b"Btom")),
            )
        return self._bbox


class PixelLayer(Layer):
    """
    Layer that has rasterized image in pixels.

    Example::

        assert layer.kind == 'pixel':
        image = layer.composite()
        image.save('layer.png')
    """

    @classmethod
    def frompil(
        cls,
        image: Image.Image,
        parent: GroupMixin,
        name: str = "Layer",
        top: int = 0,
        left: int = 0,
        compression: Compression = Compression.RLE,
        **kwargs: Any,
    ) -> "PixelLayer":
        """
        Create a PixelLayer from a PIL image for a given psd file.

        :param image: The :py:class:`~PIL.Image.Image` object to convert to photoshop
        :param parent: The parent group or PSDImage this layer belongs to.
        :param name: The name of the layer. Defaults to "Layer"
        :param top: Pixelwise offset from the top of the canvas for the new layer.
        :param left: Pixelwise offset from the left of the canvas for the new layer.
        :param compression: Compression algorithm to use for the data.

        :return: A :py:class:`~psd_tools.api.layers.PixelLayer` object
        :raises TypeError: If image is not a PIL Image
        :raises ValueError: If parent is None

        .. note::
            If the image has an alpha channel and the parent PSD mode supports
            layer transparency (e.g. RGBA, LA), the alpha is stored as the
            layer transparency channel and no extra mask is created.  For PSD
            modes that do not carry a transparency band (e.g. RGB, CMYK), the
            alpha channel is instead stored as a pixel mask
            (``USER_LAYER_MASK``).
        """
        if not isinstance(image, Image.Image):
            raise TypeError(f"Expected PIL Image, got {type(image).__name__}")
        if parent is None:
            raise ValueError("Parent cannot be None")

        # Preserve original image to extract alpha for mask creation later.
        original_image = image

        # Convert 1-bit images to 8-bit grayscale
        if image.mode == "1":
            image = image.convert("L")
        image = image.convert(parent._psd.pil_mode)
        if image.mode == "CMYK":
            image = ImageChops.invert(image)

        # Build layer record and channel data list.
        layer_record, channel_data_list = cls._build_layer_record_and_channels(
            image,
            name,
            left,
            top,
            compression,
            version=parent._psd._record.header.version,
        )
        self = cls(parent, layer_record, channel_data_list)
        parent.append(self)

        # Only create a mask if alpha was present in the original image but is
        # NOT preserved in the converted image (e.g. RGB-mode PSD with RGBA
        # input). For alpha-capable PSD modes (RGBA, LA) the alpha is already
        # stored as the transparency channel, so no extra mask is needed.
        if "A" in original_image.getbands() and "A" not in image.getbands():
            self.create_mask(
                original_image, top=top, left=left, compression=compression
            )

        return self

    def _convert_mode(self, parent: GroupMixin) -> "PixelLayer":
        """Convert the image format to match the given group."""
        if parent._psd.pil_mode == self._psd.pil_mode:
            return self

        # Get the current layer image.
        image = self.topil()
        if not isinstance(image, Image.Image):
            raise ValueError("Failed to render the image for mode conversion.")
        # Rebuild layer record and channels.
        layer_record, channel_data_list = self._build_layer_record_and_channels(
            image.convert(parent._psd.pil_mode),
            self.name,
            self.left,
            self.top,
            Compression.RLE,
            version=self._psd._record.header.version,
        )
        self._record = layer_record
        self._channels = channel_data_list
        return self

    @staticmethod
    def _build_layer_record_and_channels(
        image: Image.Image,
        name: str,
        left: int,
        top: int,
        compression: Compression,
        version: int = 1,
        **kwargs: Any,
    ) -> tuple[LayerRecord, ChannelDataList]:
        """Build layer record and channel data list from a PIL image."""

        # Initialize the layer record and channel data list.
        layer_record = LayerRecord(
            top=top,
            left=left,
            bottom=top + image.height,
            right=left + image.width,
            channel_info=[],
            **kwargs,
        )
        channel_data_list = ChannelDataList()

        # Set layer name.
        layer_record.name = name

        depth = pil_io.get_pil_depth(image.mode.rstrip("A"))

        # Transparency channel.
        transparency_data = ChannelData(compression)
        if image.has_transparency_data:
            # TODO: Need check for other types of transparency, palette for "indexed" mode
            image_bytes = image.getchannel(image.getbands().index("A")).tobytes()
        else:
            image_bytes = b"\xff" * (image.width * image.height)
        transparency_data.set_data(
            image_bytes,
            image.width,
            image.height,
            depth,
            version,
        )
        transparency_info = ChannelInfo(
            ChannelID.TRANSPARENCY_MASK, len(transparency_data.data) + 2
        )
        layer_record.channel_info.append(transparency_info)
        channel_data_list.append(transparency_data)

        # Color channels.
        for channel_index in range(pil_io.get_pil_channels(image.mode.rstrip("A"))):
            channel_data = ChannelData(compression)
            channel_data.set_data(
                image.getchannel(channel_index).tobytes(),
                image.width,
                image.height,
                depth,
                version,
            )
            channel_info = ChannelInfo(
                id=ChannelID(channel_index), length=len(channel_data.data) + 2
            )
            channel_data_list.append(channel_data)
            layer_record.channel_info.append(channel_info)

        return layer_record, channel_data_list


class SmartObjectLayer(Layer):
    """
    Layer that inserts external data.

    Use :py:attr:`~psd_tools.api.layers.SmartObjectLayer.smart_object`
    attribute to get the external data. See
    :py:class:`~psd_tools.api.smart_object.SmartObject`.

    Example::

        import io
        so = layer.smart_object
        if so.detected_filetype == 'jpg':
            # Use open(external_dir=...) when processing untrusted PSD files.
            with so.open() as f:
                image = Image.open(io.BytesIO(f.read()))
    """

    @property
    def smart_object(self) -> SmartObject:
        """
        Associated smart object.

        :return: :py:class:`~psd_tools.api.smart_object.SmartObject`.
        """
        if not hasattr(self, "_smart_object"):
            self._smart_object = SmartObject(self)
        return self._smart_object


class TypeLayer(Layer):
    """
    Layer that has text and styling information for fonts or paragraphs.

    Text is accessible at :py:attr:`~psd_tools.api.layers.TypeLayer.text`
    property. Styling information for paragraphs is in
    :py:attr:`~psd_tools.api.layers.TypeLayer.engine_dict`.
    Document styling information such as font list is is
    :py:attr:`~psd_tools.api.layers.TypeLayer.resource_dict`.

    Currently, textual information is read-only.

    Example::

        if layer.kind == 'type':
            print(layer.text)
            print(layer.engine_dict['StyleRun'])

            # Extract font for each substring in the text.
            text = layer.engine_dict['Editor']['Text'].value
            fontset = layer.resource_dict['FontSet']
            runlength = layer.engine_dict['StyleRun']['RunLengthArray']
            rundata = layer.engine_dict['StyleRun']['RunArray']
            index = 0
            for length, style in zip(runlength, rundata):
                substring = text[index:index + length]
                stylesheet = style['StyleSheet']['StyleSheetData']
                font = fontset[stylesheet['Font']]
                print('%r gets %s' % (substring, font))
                index += length
    """

    def __init__(self, *args: Any):
        super(TypeLayer, self).__init__(*args)
        self._data = self.tagged_blocks.get_data(Tag.TYPE_TOOL_OBJECT_SETTING)

    @property
    def text(self) -> str:
        """
        Text in the layer. Read-only.

        .. note:: New-line character in Photoshop is `'\\\\r'`.
        """
        return self._data.text_data.get(b"Txt ").value.rstrip("\x00")

    @property
    def text_type(self) -> TextType | None:
        """
        Text type. Read-only.

        :return:
         - :py:attr:`psd_tools.constants.TextType.POINT` for point type text
            (also known as character type)
         - :py:attr:`psd_tools.constants.TextType.PARAGRAPH` for paragraph type text
            (also known as area type)
         - `None` if text type cannot be determined or information is unavailable

        See :py:class:`psd_tools.constants.TextType`.
        """
        shapes = (
            self._engine_data.get("EngineDict", {})
            .get("Rendered", {})
            .get("Shapes", {})
            .get("Children", {})
        )
        if len(shapes) == 1:
            text_type = (
                shapes[0].get("Cookie", {}).get("Photoshop", {}).get("ShapeType", {})
            )
            if text_type in (0, 1):
                return TextType.POINT if text_type == 0 else TextType.PARAGRAPH
            else:
                logger.warning(
                    f"Cannot determine text_type of layer '{self.name}' "
                    "because information inside ShapeType was not found."
                )
        elif not shapes:
            logger.warning(
                f"Cannot determine text_type of layer '{self.name}' "
                "because information inside EngineDict was not found."
            )
        elif len(shapes) > 1:
            logger.warning(
                f"Cannot determine text_type of layer '{self.name}' "
                "because EngineDict has {len(shapes)} shapes."
            )
        return None

    @property
    def transform(self) -> tuple[float, float, float, float, float, float]:
        """Matrix (xx, xy, yx, yy, tx, ty) applies affine transformation."""
        return self._data.transform

    @property
    def _engine_data(self) -> engine_data.EngineData | engine_data.EngineData2:
        """Styling and resource information."""
        return self._data.text_data.get(b"EngineData").value

    @property
    def engine_dict(self) -> engine_data.Dict:
        """Styling information dict."""
        return self._engine_data.get("EngineDict")

    @property
    def resource_dict(self) -> engine_data.Dict:
        """Resource set."""
        return self._engine_data.get("ResourceDict")

    @property
    def document_resources(self) -> engine_data.Dict:
        """Resource set relevant to the document."""
        return self._engine_data.get("DocumentResources")

    @property
    def warp(self) -> DescriptorBlock | None:
        """Warp configuration."""
        return self._data.warp

    @property
    def typesetting(self) -> "TypeSetting":
        """
        Structured typographic data.

        Returns a :py:class:`~psd_tools.api.typesetting.TypeSetting` object
        that provides Pythonic access to fonts, paragraphs, styled runs,
        and default styles without navigating raw engine data dicts.

        Example::

            ts = layer.typesetting
            for paragraph in ts:
                print(paragraph.style.justification)
                for run in paragraph.runs:
                    print(run.text, run.style.font_name, run.style.font_size)

        See also: :py:attr:`engine_dict`, :py:attr:`resource_dict` for raw data.
        """
        if not hasattr(self, "_typesetting"):
            from psd_tools.api.typesetting import TypeSetting  # noqa: PLC0415

            self._typesetting = TypeSetting(
                self.text,
                self.engine_dict,
                self.resource_dict,
            )
        return self._typesetting

    @property
    def font_names(self) -> list[str]:
        """
        List of PostScript font names used in this text layer.

        Convenience shortcut for::

            [font.postscript_name for font in layer.typesetting.fonts]
        """
        return [font.postscript_name for font in self.typesetting.fonts]


class ShapeLayer(Layer):
    """
    Layer that has drawing in vector mask.
    """

    def __init__(self, *args: Any):
        super(ShapeLayer, self).__init__(*args)
        self._bbox: tuple[int, int, int, int] | None = None

    @property
    def left(self) -> int:
        return self.bbox[0]

    @left.setter
    def left(self, value: int) -> None:
        raise NotImplementedError("ShapeLayer left position is not writable yet.")

    @property
    def top(self) -> int:
        return self.bbox[1]

    @top.setter
    def top(self, value: int) -> None:
        raise NotImplementedError("ShapeLayer top position is not writable yet.")

    @property
    def right(self) -> int:
        return self.bbox[2]

    @property
    def bottom(self) -> int:
        return self.bbox[3]

    @property
    def bbox(self) -> tuple[int, int, int, int]:
        """(left, top, right, bottom) tuple."""
        if self._bbox is None:
            if self.has_pixels():
                self._bbox = (
                    self._record.left,
                    self._record.top,
                    self._record.right,
                    self._record.bottom,
                )
            elif self.has_origination() and not any(
                x.invalidated for x in self.origination
            ):
                lefts, tops, rights, bottoms = zip(*[x.bbox for x in self.origination])
                self._bbox = (
                    int(min(lefts)),
                    int(min(tops)),
                    int(max(rights)),
                    int(max(bottoms)),
                )
            elif self.has_vector_mask():
                if self.vector_mask is None:
                    raise ValueError(
                        "Vector mask is None despite has_vector_mask() returning True"
                    )
                bbox = self.vector_mask.bbox
                if self._psd is None:
                    raise ValueError("PSD is None for shape layer")
                self._bbox = (
                    int(round(bbox[0] * self._psd.width)),
                    int(round(bbox[1] * self._psd.height)),
                    int(round(bbox[2] * self._psd.width)),
                    int(round(bbox[3] * self._psd.height)),
                )
            else:
                self._bbox = (0, 0, 0, 0)
            if self._bbox is None:
                raise ValueError("Failed to compute bbox for shape layer")
        return self._bbox


class AdjustmentLayer(Layer):
    """Layer that applies specified image adjustment effect."""

    def __init__(self, *args: Any):
        super(AdjustmentLayer, self).__init__(*args)
        self._data = None
        if hasattr(self.__class__, "_KEY"):
            self._data = self.tagged_blocks.get_data(self.__class__._KEY)


class FillLayer(Layer):
    """Layer that fills the canvas region."""

    def __init__(self, *args: Any):
        super(FillLayer, self).__init__(*args)
        self._data = None
        if hasattr(self.__class__, "_KEY"):
            self._data = self.tagged_blocks.get_data(self.__class__._KEY)

    @property
    def right(self) -> int:
        if self._record.right:
            return self._record.right
        if self._psd is None:
            raise ValueError("Cannot determine the right position of the layer.")
        return self._psd.width

    @property
    def bottom(self) -> int:
        if self._record.bottom:
            return self._record.bottom
        if self._psd is None:
            raise ValueError("Cannot determine the right position of the layer.")
        return self._psd.height
