"""
FUSE operations for MoaT-micro-FS
"""

from __future__ import annotations

import anyio
import errno
import logging
import os
import stat
from contextlib import asynccontextmanager, suppress
from pathlib import PurePath, PurePosixPath

import pyfuse3
from pyfuse3 import (  # pylint: disable=E0611
    RENAME_EXCHANGE,
    RENAME_NOREPLACE,
    EntryAttributes,
    FileInfo,
    FUSEError,
)

from collections import defaultdict

# Typing

from typing import TYPE_CHECKING  # isort:skip

if TYPE_CHECKING:
    from pyfuse3 import RequestContext, SetattrFields, StatvfsData

    from moat.lib.rpc import SubMsgSender

    from collections.abc import AsyncIterator, Callable, Sequence
    from typing import Any, NoReturn


logger = logging.getLogger(__name__)


class Operations(pyfuse3.Operations):  # pylint: disable=I1101
    "FUSE operations to delegate to a MicroPython client"

    # pylint: disable=too-many-public-methods
    supports_dot_lookup: bool = False
    enable_writeback_cache: bool = False
    enable_acl: bool = False

    max_read: int = 256
    max_write: int = 256

    _inode_path_map: dict[pyfuse3.InodeT, PurePath] = {pyfuse3.ROOT_INODE: PurePosixPath("/")}
    _path_inode_map: dict[PurePath, pyfuse3.InodeT] = {PurePosixPath("/"): pyfuse3.ROOT_INODE}
    _lookup_cnt: dict[pyfuse3.InodeT, Callable]
    _fd_inode_map: dict[int, pyfuse3.InodeT]
    _inode_fd_map: dict[pyfuse3.InodeT, int]
    _fd_open_count: dict[int, int]

    _dir_content: dict[int, tuple[pyfuse3.InodeT, Any, RequestContext]]
    _last_dir_fh: int = 0

    _link: SubMsgSender
    _last_inode: pyfuse3.InodeT = pyfuse3.ROOT_INODE

    def __init__(self, link: SubMsgSender):
        # pylint: disable=I1101
        super().__init__()
        self._inode_path_map = {pyfuse3.ROOT_INODE: PurePosixPath("/")}
        self._path_inode_map = {PurePosixPath("/"): pyfuse3.ROOT_INODE}
        self._lookup_cnt = defaultdict(lambda: 0)
        self._fd_inode_map = dict()
        self._inode_fd_map = dict()
        self._fd_open_count = dict()
        self._dir_content = dict()

        self._link = link

    def f_open(self, fd, inode):
        "remember opened file"
        self._fd_inode_map[fd] = inode
        self._inode_fd_map[inode] = fd

    def f_close(self, fd):
        "close opened file"
        i = self._fd_inode_map.pop(fd)
        del self._inode_fd_map[i]

    def i_path(self, inode: pyfuse3.InodeT) -> PurePath:
        "return path for inode"
        try:
            return self._inode_path_map[inode]
        except KeyError:
            logger.debug("NotFound: i=%r", inode)
            raise FUSEError(errno.ENOENT) from None

    def i_add(self, path: PurePath, inode: pyfuse3.InodeT | None = None) -> pyfuse3.InodeT:
        "invent new inode number"
        try:
            return self._path_inode_map[path]
        except KeyError:
            if inode is None:
                self._last_inode += 1
                inode = pyfuse3.InodeT(self._last_inode)
            self._inode_path_map[inode] = path
            self._path_inode_map[path] = inode
            return inode

    def i_del(self, inode: pyfuse3.InodeT) -> None:
        "drop inode number"
        try:
            p = self._inode_path_map.pop(inode)
        except KeyError:
            pass
        else:
            del self._path_inode_map[p]

    def raise_error(self, err: BaseException, inode: pyfuse3.InodeT | None = None) -> NoReturn:
        "translate generic exception to FUSE exception"
        logger.warning("Error: %r", err)
        if isinstance(err, FileNotFoundError):
            if inode is not None:
                self.i_del(inode)
            raise FUSEError(errno.ENOENT)
        if isinstance(err, FileExistsError):
            raise FUSEError(errno.EEXIST)
        raise FUSEError(errno.EIO)

    @staticmethod
    def _name(name: pyfuse3.FileNameT) -> str:
        if isinstance(name, (bytes, bytearray, memoryview)):
            return bytes(name).decode("utf-8")
        return str(name)

    async def lookup(
        self, parent_inode: pyfuse3.InodeT, name: pyfuse3.FileNameT, ctx: RequestContext
    ) -> EntryAttributes:
        """Look up a directory entry by name and get its attributes.

        This method should return an `~pyfuse3.EntryAttributes` instance for the
        directory entry *name* in the directory with inode *parent_inode*.

        If there is no such entry, the method should either return an
        `~pyfuse3.EntryAttributes` instance with zero ``st_ino`` value (in which case
        the negative lookup will be cached as specified by ``entry_timeout``),
        or it should raise `~pyfuse3.FUSEError` with an errno of `errno.ENOENT` (in this
        case the negative result will not be cached).

        The file system must be able to handle lookups for :file:`.` and
        :file:`..`, no matter if these entries are returned by `readdir` or not.

        (Successful) execution of this handler increases the lookup count for
        the returned inode by one.
        """
        p = self.i_path(parent_inode) / self._name(name)
        try:
            return await self.getattr(self._path_inode_map[p], ctx)
        except KeyError:
            try:
                d = await self._link.stat(str(p))
            except Exception as err:  # pylint: disable=broad-exception-caught
                self.raise_error(err)
            else:
                inode = self.i_add(p)
                return await self._getattr(inode, ctx, _res=d)

    async def forget(self, inode_list: Sequence[tuple[pyfuse3.InodeT, int]]) -> None:
        """Decrease lookup counts for inodes in *inode_list*

        *inode_list* is a list of ``(inode, nlookup)`` tuples. This method
        should reduce the lookup count for each *inode* by *nlookup*.

        If the lookup count reaches zero, the inode is currently not known to
        the kernel. In this case, the file system will typically check if there
        are still directory entries referring to this inode and, if not, remove
        the inode.

        If the file system is unmounted, it may not have received `forget` calls
        to bring all lookup counts to zero. The filesystem needs to take care to
        clean up inodes that at that point still have non-zero lookup count
        (e.g. by explicitly calling :meth:`forget` with the current lookup
        count for every such inode after :func:`pyfuse3.main` has returned).

        This method must not raise any exceptions (not even `~pyfuse3.FUSEError`), since
        it is not handling a particular client request.
        """

        for i, _n in inode_list:
            with suppress(KeyError):
                self.i_del(i)

    async def _getattr(
        self, inode: pyfuse3.InodeT, _ctx: RequestContext, _res=None
    ) -> EntryAttributes:
        """Get attributes for *inode*

        This method should return an `~pyfuse3.EntryAttributes` instance with the
        attributes of *inode*. The `~pyfuse3.EntryAttributes.entry_timeout` attribute is
        ignored in this context.
        """

        p = self.i_path(inode)
        if _res is None:
            try:
                d = await self._link.stat(str(p))
            except Exception as err:  # pylint: disable=broad-exception-caught
                self.raise_error(err, inode)
        else:
            d = _res

        r = EntryAttributes()
        t = d["t"]
        r.st_ino = pyfuse3.InodeT(inode)
        r.entry_timeout = 300
        r.attr_timeout = 300

        r.st_size = 0
        if d["m"] == "d":
            r.st_mode = pyfuse3.ModeT(stat.S_IFDIR | 0o777)
        elif d["m"] == "f":
            r.st_mode = pyfuse3.ModeT(stat.S_IFREG | 0o666)
            r.st_size = d["s"]
        r.st_mtime_ns = t * 1_000_000_000
        r.st_ctime_ns = t * 1_000_000_000
        r.st_atime_ns = t * 1_000_000_000
        r.st_birthtime_ns = t * 1_000_000_000
        r.st_size = d.get("s", 0)
        r.st_blksize = 256
        r.st_blocks = (r.st_size - 1) // r.st_blksize + 1
        return r

    async def getattr(self, inode: pyfuse3.InodeT, ctx: RequestContext) -> EntryAttributes:
        """Get attributes for *inode*."""
        return await self._getattr(inode, ctx)

    async def setattr(
        self,
        inode: pyfuse3.InodeT,
        attr: EntryAttributes,
        fields: SetattrFields,
        fh,
        ctx: RequestContext,
    ) -> EntryAttributes:
        """Change attributes of *inode*

        *fields* specifies which attributes are to be updated. *attr*
        contains the new values of changed attributes for *inode*, and
        undefined values for all other attributes.

        Most file systems will additionally set the
        `~pyfuse3.EntryAttributes.st_ctime_ns` attribute to the current time (to
        indicate that the inode metadata was changed).

        If the syscall that is being processed received a file descriptor
        argument (like e.g. :manpage:`ftruncate(2)` or :manpage:`fchmod(2)`),
        *fh* will be the file handle returned by the corresponding call to the
        `open` handler. If the syscall was path based (like
        e.g. :manpage:`truncate(2)` or :manpage:`chmod(2)`), *fh* will be
        `None`.

        The method should return an `~pyfuse3.EntryAttributes` instance (containing both
        the changed and unchanged values).
        """

        logger.warning(
            "NotImpl: setattr: i=%r a=%r f=%r h=%r ctx=%r",
            inode,
            attr,
            fields,
            fh,
            ctx,
        )
        raise FUSEError(errno.ENOSYS)

    async def readlink(self, inode: int, ctx: RequestContext):
        """Return target of symbolic link *inode*."""

        logger.warning("NotImpl: readlink: i=%r ctx=%r", inode, ctx)
        raise FUSEError(errno.ENOSYS)

    async def mknod(
        self,
        parent_inode: pyfuse3.InodeT,
        name: pyfuse3.FileNameT,
        mode: pyfuse3.ModeT,
        rdev: int,
        ctx: RequestContext,
    ) -> EntryAttributes:
        """Create (possibly special) file

        This method must create a (special or regular) file *name* in the
        directory with inode *parent_inode*. Whether the file is special or
        regular is determined by its *mode*. If the file is neither a block nor
        character device, *rdev* can be ignored.

        The method must return an `~pyfuse3.EntryAttributes` instance with the attributes
        of the newly created directory entry.

        (Successful) execution of this handler increases the lookup count for
        the returned inode by one.
        """

        logger.warning(
            "NotImpl: mknod: p=%r n=%r m=%r d=%r ctx=%r",
            parent_inode,
            name,
            mode,
            rdev,
            ctx,
        )
        raise FUSEError(errno.ENOSYS)

    async def mkdir(
        self,
        parent_inode: pyfuse3.InodeT,
        name: pyfuse3.FileNameT,
        mode: pyfuse3.ModeT,
        ctx: RequestContext,
    ) -> EntryAttributes:
        """Create a directory

        This method must create a new directory *name* with mode *mode* in the
        directory with inode *parent_inode*.

        This method must return an `~pyfuse3.EntryAttributes` instance with the
        attributes of the newly created directory entry.

        (Successful) execution of this handler increases the lookup count for
        the returned inode by one.
        """
        mode, ctx  # noqa:B018
        p = self.i_path(parent_inode) / self._name(name)
        try:
            await self._link.mkdir(str(p))
        except Exception as err:  # pylint: disable=broad-exception-caught
            self.raise_error(err)
        return await self._getattr(self.i_add(p), ctx)

    async def unlink(
        self, parent_inode: pyfuse3.InodeT, name: pyfuse3.FileNameT, ctx: RequestContext
    ) -> None:
        """Remove a (possibly special) file

        This method must remove the (special or regular) file *name* from the
        direcory with inode *parent_inode*.

        If the inode associated with *file* (i.e., not the *parent_inode*) has a
        non-zero lookup count, or if there are still other directory entries
        referring to this inode (due to hardlinks), the file system must remove
        only the directory entry (so that future calls to `readdir` for
        *parent_inode* will no longer include *name*, but e.g. calls to
        `getattr` for *file*'s inode still succeed). (Potential) removal of the
        associated inode with the file contents and metadata must be deferred to
        the `forget` method to be carried out when the lookup count reaches zero
        (and of course only if at that point there are no more directory entries
        associated with the inode either).

        """

        ctx  # noqa:B018
        p = self.i_path(parent_inode) / self._name(name)
        try:
            await self._link.rm(str(p))
        except Exception as err:  # pylint: disable=broad-exception-caught
            self.raise_error(err)

    async def rmdir(
        self, parent_inode: pyfuse3.InodeT, name: pyfuse3.FileNameT, ctx: RequestContext
    ) -> None:
        """Remove directory *name*

        This method must remove the directory *name* from the direcory with
        inode *parent_inode*. If there are still entries in the directory,
        the method should raise ``FUSEError(errno.ENOTEMPTY)``.

        If the inode associated with *name* (i.e., not the *parent_inode*) has a
        non-zero lookup count, the file system must remove only the directory
        entry (so that future calls to `readdir` for *parent_inode* will no
        longer include *name*, but e.g. calls to `getattr` for *file*'s inode
        still succeed). Removal of the associated inode holding the directory
        contents and metadata must be deferred to the `forget` method to be
        carried out when the lookup count reaches zero.

        (Since hard links to directories are not allowed by POSIX, this method
        is not required to check if there are still other directory entries
        refering to the same inode. This conveniently avoids the ambigiouties
        associated with the ``.`` and ``..`` entries).
        """

        ctx  # noqa:B018
        p = self.i_path(parent_inode) / self._name(name)
        try:
            await self._link.rmdir(str(p))
        except Exception as err:  # pylint: disable=broad-exception-caught
            self.raise_error(err)

    async def symlink(
        self,
        parent_inode: pyfuse3.InodeT,
        name: pyfuse3.FileNameT,
        target: pyfuse3.FileNameT,
        ctx: RequestContext,
    ) -> EntryAttributes:
        """Create a symbolic link

        This method must create a symbolink link named *name* in the directory
        with inode *parent_inode*, pointing to *target*.

        The method must return an `~pyfuse3.EntryAttributes` instance with the attributes
        of the newly created directory entry.

        (Successful) execution of this handler increases the lookup count for
        the returned inode by one.
        """

        logger.warning("NotImpl: symlink: p=%r n=%r t=%r ctx=%r", parent_inode, name, target, ctx)
        raise FUSEError(errno.ENOSYS)

    async def rename(
        self,
        parent_inode_old: pyfuse3.InodeT,
        name_old: str,
        parent_inode_new: pyfuse3.InodeT,
        name_new: str,
        flags: pyfuse3.FlagT,
        ctx: RequestContext,
    ) -> None:
        """Rename a directory entry.

        This method must rename *name_old* in the directory with inode
        *parent_inode_old* to *name_new* in the directory with inode
        *parent_inode_new*.  If *name_new* already exists, it should be
        overwritten.

        *flags* may be `~pyfuse3.RENAME_EXCHANGE` or `~pyfuse3.RENAME_NOREPLACE`. If
        `~pyfuse3.RENAME_NOREPLACE` is specified, the filesystem must not overwrite
        *name_new* if it exists and return an error instead. If
        `~pyfuse3.RENAME_EXCHANGE` is specified, the filesystem must atomically exchange
        the two files, i.e. both must exist and neither may be deleted.

        Let the inode associated with *name_old* in *parent_inode_old* be
        *inode_moved*, and the inode associated with *name_new* in
        *parent_inode_new* (if it exists) be called *inode_deref*.

        If *inode_deref* exists and has a non-zero lookup count, or if there are
        other directory entries referring to *inode_deref*), the file system
        must update only the directory entry for *name_new* to point to
        *inode_moved* instead of *inode_deref*.  (Potential) removal of
        *inode_deref* (containing the previous contents of *name_new*) must be
        deferred to the `forget` method to be carried out when the lookup count
        reaches zero (and of course only if at that point there are no more
        directory entries associated with *inode_deref* either).
        """

        try:
            p = self.i_path(parent_inode_old) / name_old
            q = self.i_path(parent_inode_new) / name_new

            if flags == 0:
                await self._link.mv(s=str(p), d=str(q))
            elif flags == RENAME_EXCHANGE:
                r = f"_xfn_{os.getpid()}"  # in the root
                await self._link.mv(s=str(p), d=str(q), x=str(r))
            elif flags == RENAME_NOREPLACE:
                await self._link.mv(s=str(p), d=str(q), n=True)
            else:
                logger.warning(
                    "NotImpl: rename: p=%r n=%r d=%r n=%r f=%r ctx=%r",
                    parent_inode_old,
                    name_old,
                    parent_inode_new,
                    name_new,
                    flags,
                    ctx,
                )
                raise FUSEError(errno.ENOSYS)
        except Exception as err:  # pylint: disable=broad-exception-caught
            self.raise_error(err)

    async def link(
        self, inode, new_parent_inode, new_name, ctx: RequestContext
    ) -> EntryAttributes:
        """Create directory entry *name* in *parent_inode* refering to *inode*.

        The method must return an `~pyfuse3.EntryAttributes` instance with the
        attributes of the newly created directory entry.

        (Successful) execution of this handler increases the lookup count for
        the returned inode by one.
        """

        logger.warning(
            "NotImpl: link: i=%r p=%r n=%r ctx=%r",
            inode,
            new_parent_inode,
            new_name,
            ctx,
        )
        raise FUSEError(errno.ENOSYS)

    async def open(
        self, inode: pyfuse3.InodeT, flags: pyfuse3.FlagT, ctx: RequestContext
    ) -> FileInfo:
        """Open a inode *inode* with *flags*.

        *flags* will be a bitwise or of the open flags described in the
        :manpage:`open(2)` manpage and defined in the `os` module (with the
        exception of ``O_CREAT``, ``O_EXCL``, ``O_NOCTTY`` and ``O_TRUNC``)

        This method must return a `~pyfuse3.FileInfo` instance. The
        `~pyfuse3.FileInfo.fh` field must contain an integer file handle,
        which will be passed to the :meth:`read`, :meth:`write`,
        :meth:`flush`, :meth:`fsync` and :meth:`release` methods to
        identify the open file. The `~pyfuse3.FileInfo` instance may also
        have relevant configuration attributes set; see the `~pyfuse3.FileInfo`
        documentation for more information.
        """
        ctx  # noqa:B018
        fh = FileInfo()
        if flags & os.O_RDWR:
            m = "a+" if flags & os.O_APPEND else "r+"
        elif flags & os.O_WRONLY:
            m = "a" if flags & os.O_APPEND else "w"
        else:
            m = "r"

        try:
            fd = await self._link.open(str(self.i_path(inode)), m=m)
        except Exception as err:  # pylint: disable=broad-exception-caught
            self.raise_error(err)
        self.f_open(fd, inode)
        fh.fh = fd
        return fh

    async def read(self, fh, off, size):
        """Read *size* bytes from *fh* at position *off*

        *fh* will by an integer filehandle returned by a prior `open` or
        `create` call.

        This function should return exactly the number of bytes requested except
        on EOF or error, otherwise the rest of the data will be substituted with
        zeroes.
        """

        if size <= self.max_read:
            return await self._link.rd(fh, off, n=size)

        # OWCH. Need to break that large read up.

        data = []
        while size > 0:
            dl = min(size, self.max_read)
            buf = await self._link.rd(f=fh, o=off, n=dl)
            if buf == b"":
                break
            data.append(buf)
            if len(buf) < dl:
                break
            size -= dl
            off += dl
        return b"".join(data)

    async def write(self, fh, off, buf):
        """Write *buf* into *fh* at *off*

        *fh* will by an integer filehandle returned by a prior `open` or
        `create` call.

        This method must return the number of bytes written. However, unless the
        file system has been mounted with the ``direct_io`` option, the file
        system *must* always write *all* the provided data (i.e., return
        ``len(buf)``).
        """

        if len(buf) <= self.max_write:
            return await self._link.wr(fh, off, d=buf)

        # OWCH. Break that up.
        sent = 0
        while sent < len(buf):
            sn = await self._link.wr(
                f=fh,
                d=buf[sent : sent + self.max_write],
                o=off + sent,
            )
            sent += sn
            if sn < self.max_write:
                break
        return sent

    async def flush(self, fh):
        """Handle close() syscall.

        *fh* will by an integer filehandle returned by a prior `open` or
        `create` call.

        This method is called whenever a file descriptor is closed. It may be
        called multiple times for the same open file (e.g. if the file handle
        has been duplicated).
        """

        # pylint: disable=unnecessary-pass

    async def release(self, fh):
        """Release open file

        This method will be called when the last file descriptor of *fh* has
        been closed, i.e. when the file is no longer opened by any client
        process.

        *fh* will by an integer filehandle returned by a prior `open` or
        `create` call. Once `release` has been called, no future requests for
        *fh* will be received (until the value is re-used in the return value of
        another `open` or `create` call).

        This method may return an error by raising `~pyfuse3.FUSEError`, but the error
        will be discarded because there is no corresponding client request.
        """
        self.f_close(fh)
        await self._link.cl(fh)

    async def fsync(self, fh, datasync):
        """Flush buffers for open file *fh*

        If *datasync* is true, only the file contents should be
        flushed (in contrast to the metadata about the file).

        *fh* will by an integer filehandle returned by a prior `open` or
        `create` call.
        """
        # pylint: disable=unnecessary-pass

    async def opendir(self, inode, ctx: RequestContext):
        """Open the directory with inode *inode*

        This method should return an integer file handle. The file handle will
        be passed to the `readdir`, `fsyncdir` and `releasedir` methods to
        identify the directory.
        """
        # TODO: send an extended "ls" command and cache the state and timestamp

        p = self.i_path(inode)
        try:
            dc = await self._link.ls(str(p))
        except Exception as err:  # pylint:disable=broad-exception-caught
            self.raise_error(err)

        self._last_dir_fh += 1
        fh = self._last_dir_fh
        self._dir_content[fh] = (inode, dc, ctx)
        return fh

    async def readdir(self, fh, start_id, token):
        """Read entries in open directory *fh*.

        This method should list the contents of directory *fh* (as returned by a
        prior `opendir` call), starting at the entry identified by *start_id*.

        Instead of returning the directory entries directly, the method must
        call :meth:`~pyfuse3.readdir_reply` for each directory entry. If
        :meth:`~pyfuse3.readdir_reply` returns True, the file system must
        increase the lookup count for the provided directory entry by one
        and call :meth:`~pyfuse3.readdir_reply` again for the next entry (if
        any). If :meth:`~pyfuse3.readdir_reply` returns False, the lookup count
        must *not* be increased and the method should return without
        further calls to :meth:`~pyfuse3.readdir_reply`.

        The *start_id* parameter will be either zero (in which case listing
        should begin with the first entry) or it will correspond to a value that
        was previously passed by the file system to the :meth:`~pyfuse3.readdir_reply`
        function in the *next_id* parameter.

        If entries are added or removed during a `readdir` cycle, they may or
        may not be returned. However, they must not cause other entries to be
        skipped or returned more than once.

        :file:`.` and :file:`..` entries may be included but are not
        required. However, if they are reported the filesystem *must not*
        increase the lookup count for the corresponding inodes (even if
        :meth:`~pyfuse3.readdir_reply` returns True).
        """

        dir_inode, dc, ctx = self._dir_content[fh]
        p = self.i_path(dir_inode)

        for name in dc[start_id:]:
            inode = self.i_add(p / name)
            try:
                attr = await self.getattr(inode, ctx)
            except FileNotFoundError:
                self.i_del(inode)

            start_id += 1
            if not pyfuse3.readdir_reply(  # pylint: disable=I1101
                token,
                name.encode("utf-8"),
                attr,
                start_id,
            ):
                break

    async def releasedir(self, fh):
        """Release open directory

        This method will be called exactly once for each `opendir` call. After
        *fh* has been released, no further `readdir` requests will be received
        for it (until it is opened again with `opendir`).
        """

        del self._dir_content[fh]

    async def fsyncdir(self, fh, datasync):
        """Flush buffers for open directory *fh*

        If *datasync* is true, only the directory contents should be
        flushed (in contrast to metadata about the directory itself).
        """
        # pylint: disable=unnecessary-pass

    async def statfs(self, ctx: RequestContext) -> StatvfsData:
        """Get file system statistics

        The method must return an appropriately filled `~pyfuse3.StatvfsData` instance.
        """

        logger.warning("NotImpl: statfs: ctx=%r", ctx)
        raise FUSEError(errno.ENOSYS)

    def stacktrace(self):
        """Asynchronous debugging

        This method will be called when the ``fuse_stacktrace`` extended
        attribute is set on the mountpoint. The default implementation logs the
        current stack trace of every running Python thread. This can be quite
        useful to debug file system deadlocks.
        """

        # pylint: disable=import-outside-toplevel
        import sys  # noqa: PLC0415
        import traceback  # noqa: PLC0415

        code = []
        for threadId, frame in sys._current_frames().items():  # noqa:SLF001 pylint:disable=protected-access
            code.append(f"\n# ThreadID: {threadId}")
            for filename, lineno, name, line in traceback.extract_stack(frame):
                code.append(f"{os.path.basename(filename)}:{lineno}, in {name}")
                if line:
                    code.append("    {line.strip()}")

        logger.error("\n".join(code))

    async def setxattr(self, inode, name, value, ctx: RequestContext):
        """Set extended attribute *name* of *inode* to *value*.

        The attribute may or may not exist already. Both *name* and *value* will
        be of type `bytes`. *name* is guaranteed not to contain zero-bytes
        (``\\0``).
        """

        logger.warning("NotImpl: setxattr: i=%r n=%r v=%r ctx=%r", inode, name, value, ctx)
        raise FUSEError(errno.ENOSYS)

    async def getxattr(self, inode: int, name: bytes, ctx: RequestContext) -> bytes:
        """Return extended attribute *name* of *inode*

        If the attribute does not exist, the method must raise `~pyfuse3.FUSEError`
        with an error code of `~errno.ENOATTR`. *name* will be of type `bytes`,
        but is guaranteed not to contain zero-bytes (``\\0``).
        """

        logger.warning("NotImpl: getxattr: i=%r n=%r ctx=%r", inode, name, ctx)
        raise FUSEError(errno.ENOSYS)

    async def listxattr(
        self, inode: pyfuse3.InodeT, ctx: RequestContext
    ) -> Sequence[pyfuse3.XAttrNameT]:
        """Get list of extended attributes for *inode*

        This method must return a sequence of `bytes` objects.  The objects must
        not include zero-bytes (``\\0``).
        """

        logger.warning("NotImpl: listxattr: i=%r ctx=%r", inode, ctx)
        raise FUSEError(errno.ENOSYS)

    async def removexattr(self, inode: int, name: bytes, ctx: RequestContext) -> None:
        """Remove extended attribute *name* of *inode*

        If the attribute does not exist, the method must raise `~pyfuse3.FUSEError` with
        an error code of `~errno.ENOATTR`. *name* will be of type `bytes`, but is
        guaranteed not to contain zero-bytes (``\\0``).
        """

        logger.warning("NotImpl: removexattr: i=%r n=%r ctx=%r", inode, name, ctx)
        raise FUSEError(errno.ENOSYS)

    async def access(self, inode: int, mode: int, ctx: RequestContext) -> bool:
        """Check if requesting process has *mode* rights on *inode*.

        If the ``default_permissions`` mount option is given, this method is not
        called.

        When implementing this method, the :func:`~pyfuse3.get_sup_groups`
        function may be useful.
        """

        logger.warning("NotImpl: access: i=%r m=%r ctx=%r", inode, mode, ctx)
        raise FUSEError(errno.ENOSYS)

    async def create(
        self,
        parent_inode: pyfuse3.InodeT,
        name: pyfuse3.FileNameT,
        mode: pyfuse3.ModeT,
        flags: pyfuse3.FlagT,
        ctx: RequestContext,
    ) -> tuple[FileInfo, EntryAttributes]:
        """Create a file with permissions *mode* and open it with *flags*

        (Successful) execution of this handler increases the lookup count for
        the returned inode by one.
        """

        mode  # noqa:B018
        p = self.i_path(parent_inode) / self._name(name)
        try:
            i = self._path_inode_map[p]
            gen = False
        except KeyError:
            i = self.i_add(p)
            gen = True

        try:
            r = await self._link.new(str(p))
        except Exception as err:  # pylint: disable=broad-exception-caught
            if gen:
                self.i_del(i)
            self.raise_error(err)
        else:
            i = self.i_add(p)
            h = await self.open(i, flags, ctx)
            a = await self._getattr(i, ctx, _res=r)
            return (h, a)


@asynccontextmanager
async def wrap(
    link: SubMsgSender, path: str | PurePath | anyio.Path, blocksize: int = 0, debug: int = 1
) -> AsyncIterator[None]:
    """
    Context manager that mounts a satellite file system locally
    """
    operations = Operations(link)
    if blocksize:
        operations.max_read = blocksize
        operations.max_write = blocksize

    logger.debug("Mounting...")
    # Whether max_read needs to be set is anybody's guess, so try both
    for mx in (False, True):
        fuse_options = set(pyfuse3.default_options)  # pylint: disable=I1101
        fuse_options.add("fsname=moat_fs")
        fuse_options.add(f"max_read={operations.max_read if mx else 0}")
        # fuse_options.add(f'max_write={operations.max_write}')
        if debug > 1:
            fuse_options.add("debug")
        try:
            pyfuse3.init(operations, str(path), fuse_options)  # pylint: disable=I1101
        except Exception:
            if mx:
                raise
        else:
            break

    logger.debug("Entering main loop..")
    async with anyio.create_task_group() as tg:

        async def fuse_main():
            try:
                await pyfuse3.main()
            except anyio.get_cancelled_exc_class():
                pyfuse3.close(unmount=True)
                raise
            except BaseExceptionGroup as exc:
                exc = exc.split(anyio.get_cancelled_exc_class())[1]
                pyfuse3.close(unmount=exc is None)
                raise
            except BaseException:
                pyfuse3.close(unmount=False)
                raise
            else:
                pyfuse3.close(unmount=True)

        try:
            tg.start_soon(fuse_main)  # pylint: disable=I1101
            yield None

        finally:
            tg.cancel_scope.cancel()
