"""
.. testsetup::

    import io
    import nbtlib
    from nbtlib import *

The library supports reading and writing nbt data in all its forms and
treats everything as uncompressed big-endian nbt by default.

You can load nbt files with the :func:`load` function.

.. doctest::

    >>> nbtlib.load("docs/hello_world.nbt")
    <File 'hello world': Compound({...})>

The function will figure out by itself if the file is gzipped before loading
it. You can set the ``byteorder`` parameter to ``"little"`` if the file is
little-endian.

.. doctest::

    >>> nbtlib.load("docs/hello_world_little.nbt", byteorder="little")
    <File 'hello world': Compound({...})>

You can create new nbt files by instantiating the :class:`File` class with
the desired nbt data and calling the :meth:`File.save` method.


.. doctest::

    >>> nbt_file = nbtlib.File({"demo": Compound({"counter": Int(0)})})
    >>> nbt_file.save("docs/demo.nbt")
    >>> nbtlib.load("docs/demo.nbt")
    <File 'demo': Compound({'counter': Int(0)})>

The :meth:`File.save` method can output gzipped or little-endian nbt
by using the ``gzipped`` and ``byteorder`` arguments respectively.

.. doctest::

    >>> demo = nbtlib.load("docs/demo.nbt")

    >>> # overwrite
    >>> demo.save()

    >>> # make a gzipped copy
    >>> demo.save("docs/demo_copy.nbt", gzipped=True)

    >>> # convert the file to little-endian
    >>> demo.save("docs/demo_little.nbt", byteorder="little")
"""


__all__ = ["load", "Root", "File"]


import gzip

from .tag import Compound


def load(filename, *, gzipped=None, byteorder="big"):
    """Load the nbt file at the specified location.

    .. doctest::

        >>> nbt_file = nbtlib.load("docs/bigtest.nbt")
        >>> nbt_file.root["stringTest"]
        String('HELLO WORLD THIS IS A TEST STRING ÅÄÖ!')

    The function returns an instance of the dict-like :class:`File` class
    holding all the data that could be parsed from the binary file.
    You can retrieve items from the :attr:`Root.root` property with the index
    operator just like with regular python dictionaries.

    Note that the :class:`File` instance can be used as a context
    manager. The :meth:`File.save` method is called automatically at the
    end of the ``with`` statement.

    .. doctest::

        >>> with nbtlib.load("docs/demo.nbt") as demo:
        ...     demo.root["counter"] = nbtlib.Int(demo.root["counter"] + 1)


    Arguments:
        gzipped: Whether the file is gzipped or not.

            If the argument is not specified, the function will read the
            magic number of the file to figure out if the file is
            gzipped.

            .. doctest::

                >>> filename = "docs/hello_world.nbt"
                >>> nbt_file = nbtlib.load(filename, gzipped=False)
                >>> nbt_file.gzipped
                False

            The function simply delegates to :meth:`File.load`
            when the argument is specified explicitly.

        byteorder: Whether the file is big-endian or little-endian.

            The default value is ``"big"`` so files are interpreted as
            big-endian if the argument is not specified. You can set the
            argument to ``"little"`` to handle little-endian nbt data.

            .. doctest::

                >>> filename = "docs/hello_world_little.nbt"
                >>> nbt_file = nbtlib.load(filename, byteorder="little")
                >>> nbt_file.byteorder
                'little'
    """
    # Delegate to `File.load` if the gzipped argument is set explicitly
    if gzipped is not None:
        return File.load(filename, gzipped, byteorder)

    # Read the magic number otherwise and call `File.from_fileobj` with
    # the appropriate file object
    with open(filename, "rb") as fileobj:
        magic_number = fileobj.read(2)
        fileobj.seek(0)

        if magic_number == b"\x1f\x8b":
            fileobj = gzip.GzipFile(fileobj=fileobj)

        return File.from_fileobj(fileobj, byteorder)


class Root(Compound):
    """Class representing a compound nbt root.

    This class inherits from :class:`nbtlib.tag.Compound` and defines
    properties that can be used on the root compound tag of nbt files.
    """

    @property
    def root_name(self):
        """The name of the root nbt tag.

        Used by the :attr:`root` property to retrieve the first key of
        the root compound tag. You can also use the property to change
        the name of the root tag of the file.

        .. doctest::

            >>> compound = Root({"Data": Compound({})})
            >>> compound.root_name
            'Data'
            >>> compound.root_name = "NewData"
            >>> compound
            Root({'NewData': Compound({})})
        """
        return next(iter(self), None)

    @root_name.setter
    def root_name(self, value):
        self[value] = self.pop(self.root_name)

    @property
    def root(self):
        """The root nbt tag of the file.

        This is a simple convenience shortcut to ``tag[tag.root_name]``.

        .. doctest::

            >>> compound.root
            Compound({})
            >>> compound['NewData']
            Compound({})
        """
        return self[self.root_name]

    @root.setter
    def root(self, value):
        self[self.root_name] = value


class File(Root):
    r"""Class representing a compound nbt file.

    .. doctest::

        >>> nbt_file = nbtlib.File({
        ...     "Data": nbtlib.Compound({
        ...         "hello": nbtlib.String("world")
        ...    })
        ... })

    The class inherits from :class:`Root`, so all the builtin ``dict``
    operations inherited by the :class:`nbtlib.tag.Compound` class are
    also available on :class:`File` instances.

    .. doctest::

        >>> nbt_file.items()
        dict_items([('Data', Compound({'hello': String('world')}))])
        >>> nbt_file["Data"]
        Compound({'hello': String('world')})

    You can write nbt data to an already opened file-like object with the
    inherited :meth:`nbtlib.tag.Compound.write` method.

    .. doctest::

        >>> fileobj = io.BytesIO()
        >>> nbt_file.write(fileobj)
        >>> fileobj.getvalue()
        b'\n\x00\x04Data\x08\x00\x05hello\x00\x05world\x00'

    If you need to load files from an already opened file-like object, you can
    use the inherited :meth:`nbtlib.tag.Compound.parse` classmethod.

    .. doctest::

        >>> fileobj.seek(0)
        0
        >>> nbtlib.File.parse(fileobj) == nbt_file
        True

    Attributes:
        filename:
            The name of the file, ``None`` by default. The attribute is
            set automatically when the file is returned from the
            :func:`load` helper function and can also be set in the
            constructor.

            .. doctest::

                >>> nbt_file.filename is None
                True
                >>> nbtlib.load("docs/demo.nbt").filename
                'docs/demo.nbt'

        gzipped:
            Boolean indicating if the file is gzipped. The attribute can
            also be set in the constructor. New files are uncompressed
            by default.

            .. doctest::

                >>> nbtlib.File(nbt_file, gzipped=True).gzipped
                True

        byteorder:
            The byte order, either ``"big"`` or ``"little"``. The
            attribute can also be set in the constructor. New files are
            big-endian by default.

            .. doctest::

                >>> nbtlib.File(nbt_file, byteorder="little").byteorder
                'little'
    """

    # We remove the inherited end tag as the end of nbt files is
    # specified by the end of the file
    end_tag = b""

    def __init__(self, *args, gzipped=False, byteorder="big", filename=None):
        super().__init__(*args)
        self.filename = filename
        self.gzipped = gzipped
        self.byteorder = byteorder

    @classmethod
    def from_fileobj(cls, fileobj, byteorder="big"):
        """Load an nbt file from a proper file object.

        The method is used by the :func:`load` helper function when the
        ``gzipped`` keyword-only argument is not specified explicitly.

        Arguments:
            fileobj:
                Can be either a standard ``io.BufferedReader`` for
                uncompressed nbt or a ``gzip.GzipFile`` for gzipped nbt
                data. The function simply calls the inherited
                :meth:`nbtlib.tag.Compound.parse` classmethod and sets the
                :attr:`filename` and :attr:`gzipped` attributes depending
                on the argument.

            byteorder:
                Can be either ``"big"`` or ``"little"``. The argument is
                forwarded to :meth:`nbtlib.tag.Compound.parse`.
        """
        self = cls.parse(fileobj, byteorder)
        self.filename = getattr(fileobj, "name", self.filename)
        self.gzipped = isinstance(fileobj, gzip.GzipFile)
        self.byteorder = byteorder
        return self

    @classmethod
    def load(cls, filename, gzipped, byteorder="big"):
        """Read, parse and return the nbt file at the specified location.

        The method is used by the :func:`load` helper function when the
        ``gzipped`` keyword-only argument is specified explicitly.
        The function opens the file and uses :meth:`from_fileobj` to return
        the :class:`File` instance.

        Arguments:
            filename: The name of the file.
            gzipped: Whether the file is gzipped or not.
            byteorder: Can be either ``"big"`` or ``"little"``.
        """
        open_file = gzip.open if gzipped else open
        with open_file(filename, "rb") as fileobj:
            return cls.from_fileobj(fileobj, byteorder)

    def save(self, filename=None, *, gzipped=None, byteorder=None):
        """Write the file at the specified location.

        The method is called without any argument at the end of ``with``
        statements when the :class:`File` instance is used as a
        context manager.

        .. doctest::

            >>> with demo:
            ...     demo.root['counter'] = nbtlib.Int(0)

        This essentially overwrites the file at the end of the ``with`` statement.

        Arguments:
            filename: The name of the file. Defaults to the instance's :attr:`filename` attribute.
            gzipped: Whether the file should be gzipped. Defaults to the instance's :attr:`gzipped` attribute.
            byteorder: Whether the file should be big-endian or little-endian. Defaults to the instance's :attr:`byteorder` attribute.
        """
        if gzipped is None:
            gzipped = self.gzipped
        if filename is None:
            filename = self.filename

        if filename is None:
            raise ValueError("No filename specified")

        open_file = gzip.open if gzipped else open
        with open_file(filename, "wb") as fileobj:
            self.write(fileobj, byteorder or self.byteorder)

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.save()

    def __repr__(self):
        return f"<{self.__class__.__name__} {self.root_name!r}: {self.root!r}>"