from __future__ import annotations

from typing import (
    TYPE_CHECKING,
    Any,
    Dict,
    Iterator,
    KeysView,
    Optional,
    Sequence,
    Type,
    overload,
)

from attrs import define, field
from fontTools.ufoLib.glifLib import GlyphSet

from ufoLib2.constants import DEFAULT_LAYER_NAME
from ufoLib2.objects.glyph import Glyph
from ufoLib2.objects.lib import (
    Lib,
    _convert_Lib,
    _get_lib,
    _get_tempLib,
    _set_lib,
    _set_tempLib,
)
from ufoLib2.objects.misc import (
    BoundingBox,
    _deepcopy_unlazify_attrs,
    _getstate_unlazify_attrs,
    _prune_object_libs,
    _setstate_attrs,
    unionBounds,
)
from ufoLib2.serde import serde
from ufoLib2.typing import T

if TYPE_CHECKING:
    from cattrs import Converter

_GLYPH_NOT_LOADED = Glyph(name="___UFOLIB2_LAZY_GLYPH___")


def _convert_glyphs(value: dict[str, Glyph] | Sequence[Glyph]) -> dict[str, Glyph]:
    result: dict[str, Glyph] = {}
    if isinstance(value, dict):
        glyph_ids = set()
        for name, glyph in value.items():
            if not isinstance(glyph, Glyph):
                raise TypeError(f"Expected Glyph, found {type(glyph).__name__}")
            if glyph is not _GLYPH_NOT_LOADED:
                glyph_id = id(glyph)
                if glyph_id in glyph_ids:
                    raise KeyError(f"{glyph!r} can't be added twice")
                glyph_ids.add(glyph_id)
                if glyph.name is None:
                    glyph._name = name
                elif glyph.name != name:
                    raise ValueError(
                        "glyph has incorrect name: "
                        f"expected '{name}', found '{glyph.name}'"
                    )
            result[name] = glyph
    else:
        for glyph in value:
            if not isinstance(glyph, Glyph):
                raise TypeError(f"Expected Glyph, found {type(glyph).__name__}")
            if glyph.name is None:
                raise ValueError(f"{glyph!r} has no name; can't add it to Layer")
            if glyph.name in result:
                raise KeyError(f"glyph named '{glyph.name}' already exists")
            result[glyph.name] = glyph
    return result


@serde
@define
class Layer:
    """Represents a Layer that holds Glyph objects.

    See http://unifiedfontobject.org/versions/ufo3/glyphs/layerinfo.plist/.

    Note:
        Various methods that work on Glyph objects take a ``layer`` attribute, because
        the UFO data model prescribes that Components within a Glyph object refer to
        glyphs *within the same layer*.

    Behavior:
        Layer behaves **partly** like a dictionary of type ``Dict[str, Glyph]``.
        Unless the font is loaded eagerly (with ``lazy=False``), the Glyph objects
        by default are only loaded into memory when accessed.

        To get the number of glyphs in the layer::

            glyphCount = len(layer)

        To iterate over all glyphs::

            for glyph in layer:
                ...

        To check if a specific glyph exists::

            exists = "myGlyphName" in layer

        To get a specific glyph::

            layer["myGlyphName"]

        To delete a specific glyph::

            del layer["myGlyphName"]
    """

    _name: str = field(default=DEFAULT_LAYER_NAME, metadata={"omit_if_default": False})
    _glyphs: Dict[str, Glyph] = field(factory=dict, converter=_convert_glyphs)
    color: Optional[str] = None
    """The color assigned to the layer."""

    _lib: Lib = field(factory=Lib, converter=_convert_Lib)
    """The layer's lib for mapping string keys to arbitrary data."""

    _default: bool = False
    """Can set to True to mark a layer as default. If layer name is 'public.default'
    the default attribute is automatically True. Exactly one layer must be marked as
    default in a font."""

    _tempLib: Lib = field(factory=Lib, converter=_convert_Lib)
    """A temporary map of arbitrary plist values."""

    _lazy: Optional[bool] = field(default=None, init=False, eq=False)
    _glyphSet: Any = field(default=None, init=False, eq=False)

    def __attrs_post_init__(self) -> None:
        if self._name == DEFAULT_LAYER_NAME and not self._default:
            # layer named 'public.default' is default by definition
            self._default = True

    @classmethod
    def read(
        cls, name: str, glyphSet: GlyphSet, lazy: bool = True, default: bool = False
    ) -> Layer:
        """Instantiates a Layer object from a
        :class:`fontTools.ufoLib.glifLib.GlyphSet`.

        Args:
            name: The name of the layer.
            glyphSet: The GlyphSet object to read from.
            lazy: If True, load glyphs as they are accessed. If False, load everything
                up front.
        """
        glyphNames = glyphSet.keys()
        glyphs: dict[str, Glyph]
        if lazy:
            glyphs = {gn: _GLYPH_NOT_LOADED for gn in glyphNames}
        else:
            glyphs = {}
            for glyphName in glyphNames:
                glyph = Glyph(glyphName)
                glyphSet.readGlyph(glyphName, glyph, glyph.getPointPen())
                glyphs[glyphName] = glyph
        self = cls(name, glyphs, default=default)
        self._lazy = lazy
        if lazy:
            self._glyphSet = glyphSet
        glyphSet.readLayerInfo(self)
        return self

    def unlazify(self) -> None:
        """Load all glyphs into memory."""
        if self._lazy:
            for _ in self:
                pass
        self._lazy = False

    __deepcopy__ = _deepcopy_unlazify_attrs

    __getstate__ = _getstate_unlazify_attrs
    __setstate__ = _setstate_attrs

    def __contains__(self, name: object) -> bool:
        return name in self._glyphs

    def __delitem__(self, name: str) -> None:
        del self._glyphs[name]

    def __getitem__(self, name: str) -> Glyph:
        glyph_object = self._glyphs[name]
        if glyph_object is _GLYPH_NOT_LOADED:
            return self.loadGlyph(name)
        return glyph_object

    def __setitem__(self, name: str, glyph: Glyph) -> None:
        if not isinstance(glyph, Glyph):
            raise TypeError(f"Expected Glyph, found {type(glyph).__name__}")
        glyph._name = name
        self._glyphs[name] = glyph

    def __iter__(self) -> Iterator[Glyph]:
        for name in self._glyphs:
            yield self[name]

    def __len__(self) -> int:
        return len(self._glyphs)

    def __repr__(self) -> str:
        n = len(self._glyphs)
        return "<{}.{} '{}' ({}{}) at {}>".format(
            self.__class__.__module__,
            self.__class__.__name__,
            self._name,
            "default, " if self._default else "",
            "empty" if n == 0 else "{} glyph{}".format(n, "s" if n > 1 else ""),
            hex(id(self)),
        )

    def get(self, name: str, default: T | None = None) -> T | Glyph | None:
        """Return the Glyph object for name if it is present in this layer,
        otherwise return ``default``."""
        try:
            return self[name]
        except KeyError:
            return default

    def keys(self) -> KeysView[str]:
        """Returns a list of glyph names."""
        return self._glyphs.keys()

    @overload
    def pop(self, key: str) -> Glyph: ...

    @overload
    def pop(self, key: str, default: Glyph | T = ...) -> Glyph | T: ...

    def pop(self, key: str, default: Glyph | T = KeyError) -> Glyph | T:  # type: ignore
        """Remove and return glyph from layer.

        Args:
            key: The name of the glyph.
            default: What to return if there is no glyph with the given name.
        """
        # NOTE: We can't defer to self._glyphs.pop because we must load glyphs
        try:
            glyph = self[key]
        except KeyError:
            if default is KeyError:
                raise
            glyph = default  # type: ignore
        else:
            del self[key]
        return glyph

    @property
    def name(self) -> str:
        """The name of the layer."""
        return self._name

    lib = property(_get_lib, _set_lib)

    tempLib = property(_get_tempLib, _set_tempLib)

    @property
    def default(self) -> bool:
        """Read-only property. To change the font's default layer use the
        LayerSet.defaultLayer property setter."""
        return self._default

    @property
    def bounds(self) -> BoundingBox | None:
        """Returns the (xMin, yMin, xMax, yMax) bounding box of the layer,
        taking the actual contours into account.

        |defcon_compat|
        """
        bounds = None
        for glyph in self:
            bounds = unionBounds(bounds, glyph.getBounds(self))
        return bounds

    @property
    def controlPointBounds(self) -> BoundingBox | None:
        """Returns the (xMin, yMin, xMax, yMax) bounding box of the layer,
        taking only the control points into account.

        |defcon_compat|
        """
        bounds = None
        for glyph in self:
            bounds = unionBounds(bounds, glyph.getControlBounds(self))
        return bounds

    def addGlyph(self, glyph: Glyph) -> None:
        """Appends glyph object to the this layer unless its name is already
        taken."""
        self.insertGlyph(glyph, overwrite=False, copy=False)

    def insertGlyph(
        self,
        glyph: Glyph,
        name: str | None = None,
        overwrite: bool = True,
        copy: bool = True,
    ) -> None:
        """Inserts Glyph object into this layer.

        Args:
            glyph: The Glyph object.
            name: The name of the glyph.
            overwrite: If True, overwrites (read: deletes) glyph with the same name if
                it exists. If False, raises KeyError.
            copy: If True, copies the Glyph object before insertion. If False, inserts
                as is.
        """
        if copy:
            glyph = glyph.copy()
        if name is not None:
            glyph._name = name
        if glyph.name is None:
            raise ValueError(f"{glyph!r} has no name; can't add it to Layer")
        if not overwrite and glyph.name in self._glyphs:
            raise KeyError(f"glyph named '{glyph.name}' already exists")
        self._glyphs[glyph.name] = glyph

    def loadGlyph(self, name: str) -> Glyph:
        """Load and return Glyph object."""
        # XXX: Remove and let __getitem__ do it?
        glyph = Glyph(name)
        self._glyphSet.readGlyph(name, glyph, glyph.getPointPen())
        self._glyphs[name] = glyph
        return glyph

    def newGlyph(self, name: str) -> Glyph:
        """Creates and returns new Glyph object in this layer with name."""
        if name in self._glyphs:
            raise KeyError(f"glyph named '{name}' already exists")
        self._glyphs[name] = glyph = Glyph(name)
        return glyph

    def renameGlyph(self, name: str, newName: str, overwrite: bool = False) -> None:
        """Renames a Glyph object in this layer.

        Args:
            name: The old name.
            newName: The new name.
            overwrite: If False, raises exception if newName is already taken.
                If True, overwrites (read: deletes) the old Glyph object.
        """
        if name == newName:
            return
        if not overwrite and newName in self._glyphs:
            raise KeyError(f"target glyph named '{newName}' already exists")
        # pop and set name
        glyph = self.pop(name)
        glyph._name = newName
        # add it back
        self._glyphs[newName] = glyph

    def instantiateGlyphObject(self) -> Glyph:
        """Returns a new Glyph instance.

        |defcon_compat|
        """
        return Glyph()

    def write(self, glyphSet: GlyphSet, saveAs: bool = True) -> None:
        """Write Layer to a :class:`fontTools.ufoLib.glifLib.GlyphSet`.

        Args:
            glyphSet: The GlyphSet object to write to.
            saveAs: If True, tells the writer to save out-of-place. If False, tells the
                writer to save in-place. This affects how resources are cleaned before
                writing.
        """
        glyphs = self._glyphs
        if not saveAs:
            for name in set(glyphSet.contents).difference(glyphs):
                glyphSet.deleteGlyph(name)
        for name, glyph in glyphs.items():
            if glyph is _GLYPH_NOT_LOADED:
                if saveAs:
                    glyph = self.loadGlyph(name)
                else:
                    continue
            _prune_object_libs(glyph.lib, _fetch_glyph_identifiers(glyph))
            glyphSet.writeGlyph(
                name, glyphObject=glyph, drawPointsFunc=glyph.drawPoints
            )
        glyphSet.writeContents()
        glyphSet.writeLayerInfo(self)
        if saveAs:
            # all glyphs are loaded by now, no need to keep ref to glyphSet
            self._glyphSet = None

    def _unstructure(self, converter: Converter) -> dict[str, Any]:
        # omit glyph name attribute, already used as key
        glyphs: dict[str, dict[str, Any]] = {}
        for glyph_name in self._glyphs:
            g = converter.unstructure(self[glyph_name])
            assert glyph_name == g.pop("name")
            glyphs[glyph_name] = g
        d: dict[str, Any] = {
            # never omit name even if == 'public.default' as that acts as
            # the layer's "key" in the layerSet.
            "name": self._name,
        }
        default: Any
        for key, value, default in [
            ("default", self._default, self._name == DEFAULT_LAYER_NAME),
            ("glyphs", glyphs, {}),
            ("lib", self._lib, {}),
            ("tempLib", self._tempLib, {}),
        ]:
            if not converter.omit_if_default or value != default:
                d[key] = value
        if self.color is not None:
            d["color"] = self.color
        return d

    @staticmethod
    def _structure(
        data: dict[str, Any], cls: Type[Layer], converter: Converter
    ) -> Layer:
        return cls(
            name=data.get("name", DEFAULT_LAYER_NAME),
            glyphs={
                k: converter.structure(v, Glyph)
                for k, v in data.get("glyphs", {}).items()
            },
            color=data.get("color"),
            lib=converter.structure(data.get("lib", {}), Lib),
            default=data.get("default", False),
            tempLib=converter.structure(data.get("tempLib", {}), Lib),
        )


def _fetch_glyph_identifiers(glyph: Glyph) -> set[str]:
    """Returns all identifiers in use in a glyph."""

    identifiers = set()
    for anchor in glyph.anchors:
        if anchor.identifier is not None:
            identifiers.add(anchor.identifier)
    for guideline in glyph.guidelines:
        if guideline.identifier is not None:
            identifiers.add(guideline.identifier)
    for contour in glyph.contours:
        if contour.identifier is not None:
            identifiers.add(contour.identifier)
        for point in contour:
            if point.identifier is not None:
                identifiers.add(point.identifier)
    for component in glyph.components:
        if component.identifier is not None:
            identifiers.add(component.identifier)
    return identifiers
