# Source code for nobodd.path

    # nobodd: a boot configuration tool for the Raspberry Pi
    #
    # Copyright (c) 2023-2026 Dave Jones <dave.jones@canonical.com>
    # Copyright (c) 2023-2026 Canonical Ltd.
    #
    # SPDX-License-Identifier: GPL-3.0

    import io
    import os
    import re
    import stat
    import errno
    import fnmatch
    import weakref
    import warnings
    import datetime as dt
    from itertools import zip_longest

    from . import lang
    from .fat import DirectoryEntry, lfn_valid
    from .tools import encode_timestamp, decode_timestamp

    class FatPath:
        """
        A :class:`~pathlib.Path`-like object representing a filepath within an
        associated :class:`~nobodd.fs.FatFileSystem`.

        There is rarely a need to construct this class directly. Instead, instances
        should be obtained via the :attr:`~nobodd.fs.FatFileSystem.root` property
        of a :class:`~nobodd.fs.FatFileSystem`. If constructed directly, *fs* is a
        :class:`~nobodd.fs.FatFileSystem` instance, and *pathsegments* is the
        sequence of strings to be joined with a path separator into the path.

        Instances provide almost all the facilities of the :class:`pathlib.Path`
        class they are modeled after, including the crucial :meth:`open` method,
        :meth:`iterdir`, :meth:`glob`, and :meth:`rglob` for enumerating
        directories, :meth:`stat`, :meth:`is_dir`, and :meth:`is_file` for querying
        information about files, division for construction of new paths, and all
        the usual :attr:`name`, :attr:`parent`, :attr:`stem`, and :attr:`suffix`
        attributes. When the :class:`~nobodd.fs.FatFileSystem` is writable, then
        :meth:`unlink`, :meth:`touch`, :meth:`mkdir`, :meth:`rmdir`, and
        :meth:`rename` may also be used.

        Instances are also comparable for the purposes of sorting, but only within
        the same :class:`~nobodd.fs.FatFileSystem` instance (comparisons across
        file-system instances raise :exc:`TypeError`).
        """
        __slots__ = ('_fs', '_index', '_entry', '_parts', '_resolved')
        sep = '/'

        def __init__(self, fs, *pathsegments):
            self._fs = weakref.ref(fs)
            self._index = None
            self._entry = None
            self._parts = get_parts(*pathsegments)
            for index, part in enumerate(self._parts):
                if index == 0 and not part:
                    continue # ignore root marker
                elif part in ('.', '..'):
                    continue # ignore path components
                elif not lfn_valid(part):
                    raise ValueError(lang._(
                        'invalid name {str_self!r}'
                        .format(str_self=str(self))))
            self._resolved = False

        def __repr__(self):
            return f'{self.__class__.__name__}(<fs>, {str(self)!r})'

        def __str__(self):
            if self._parts == ('',):
                return self.sep
            else:
                return self.sep.join(self._parts)

        def _get_fs(self):
            """
            Check the weak reference to the FatFileSystem. If it's still live,
            return the strong reference result. If it's disappeared, raise an
            :exc:`OSError` exception indicating the file-system has been closed.
            """
            fs = self._fs()
            if fs is None:
                raise OSError(lang._(
                    'FatFileSystem containing {self!s} is closed'
                    .format(self=self)))
            return fs

        @classmethod
        def _from_index(cls, fs, index, path=sep):
            """
            Internal class method for constructing an instance from *fs* (a
            :class:`~nobodd.fs.FatFileSystem` instance), *index* (a
            :class:`~nobodd.fs.FatDirectory` instance), and a *path*.

            This is only used in the construction of directory instances.
            """
            self = cls(fs, path)
            self._index = index
            self._resolved = True
            return self

        @classmethod
        def _from_entry(cls, fs, index, entry, path=sep):
            """
            Internal class method for constructing an instance from *fs* (a
            :class:`~nobodd.fs.FatFileSystem` instance), *index* (a
            :class:`~nobodd.fs.FatDirectory instance), *entries* (a sequence of
            associated :class:`~nobodd.fat.LongFilenameEntry` and
            :class:`~nobodd.fat.DirectoryEntry` instances which must exist within
            *index*), and a *path*.
            """
            if entry.attr & 0x10: # directory
                cluster = get_cluster(entry, fs.fat_type)
                self = cls._from_index(fs, fs.open_dir(cluster), path)
            else:
                self = cls(fs, path)
                self._index = index
            self._entry = entry
            self._resolved = True
            return self

        def _resolve(self):
            """
            Internal method which "resolves" a constructed path to find the
            corresponding structures on disk (if the path exists).
            """
            if self._resolved:
                return
            assert self._index is None
            assert self._entry is None
            try:
                head, *parts = self.parts
                if head != self.sep:
                    raise ValueError(lang._('relative FatPath cannot be resolved'))
                fs = self._get_fs()
                path = fs.root
                with fs.lock.read:
                    while parts:
                        path._must_exist()
                        path._must_be_dir()
                        head, *parts = parts
                        try:
                            path = FatPath._from_entry(
                                fs, path._index, path._index[head],
                                str(path / head))
                        except KeyError:
                            # Path doesn't exist
                            return
                self._index = path._index
                self._entry = path._entry
            finally:
                self._resolved = True

        def _refresh(self):
            """
            Internal method which "refreshes" the _entry field to ensure that, if
            the first cluster of the file has changed (because the file was emptied
            and then re-filled) we have the correct value.

            This method should be called before using the cluster of the _entry
            field, unless you are certain the cluster cannot be wrong (e.g. the
            file backing sub-directories can never be emptied due to the "." and
            ".." entries so it never changes).

            The entry will be refreshed by searching for an entry in the _index
            with a matching name, i.e. this is no good if the calling method has
            renamed the entry.
            """
            if self._resolved:
                assert self._index
                assert self._entry
                assert not self._entry.attr & 0x10, 'no need for _refresh on dirs'
                try:
                    self._entry = self._index[self.name]
                except KeyError:
                    raise FileNotFoundError(lang._(
                        'Directory entry for {self} disappeared'
                        .format(self=self)))
            else:
                self._resolve()

        def _must_exist(self):
            """
            Internal method which is called to check that a constructed path
            actually exists in the file-system. Calls :meth:`_resolve` implicitly.
            """
            if not self.exists():
                raise FileNotFoundError(lang._(
                    'No such file or directory: {self}'.format(self=self)))

        def _must_not_exist(self):
            """
            Internal method which is called to check that a constructed path
            does not exist in the file-system. Calls :meth:`_resolve` implicitly.
            """
            if self.exists():
                raise FileExistsError(lang._(
                    'File exists: {self}'.format(self=self)))

        def _must_be_dir(self):
            """
            Internal method which is called to check that a constructed path is a
            directory. Calls :meth:`_resolve` implicitly.
            """
            if not self.is_dir():
                raise NotADirectoryError(lang._(
                    'Not a directory: {self}'.format(self=self)))

        def _must_not_be_dir(self):
            """
            Internal method which is called to check that a constructed path is not
            a directory. Calls :meth:`_resolve` implicitly.
            """
            if self.is_dir():
                raise IsADirectoryError(lang._(
                    'Is a directory: {self}'.format(self=self)))

        def open(self, mode='r', buffering=-1, encoding=None, errors=None,
                 newline=None):
            """
            Open the file pointed to by the path, like the built-in
            :func:`~io.open` function does. The *mode*, *buffering*, *encoding*,
            *errors* and *newline* options are as for the :func:`~io.open`
            function. If successful, a :class:`~nobodd.fs.FatFile` instance is
            returned.
            """
            fs = self._get_fs()
            # Check the mode is valid and matches our expectations (can't open a
            # directory, can't read a non-existent file, etc.)
            if set(mode) - set('rwaxb+'):
                raise ValueError(lang._(
                    'invalid file mode {mode!r}'.format(mode=mode)))
            if len(set(mode) & set('rwax')) != 1:
                raise ValueError(lang._(
                    'must have exactly one of read, write, append, exclusive '
                    'creation mode'))
            if fs.readonly and set(mode) & set('wax+'):
                raise PermissionError(lang._('fs is read-only'))
            if 'b' in mode:
                if encoding is not None:
                    raise ValueError(lang._(
                        "binary mode doesn't take an encoding argument"))
                if errors is not None:
                    raise ValueError(lang._(
                        "binary mode doesn't take an errors argument"))
                if newline is not None:
                    raise ValueError(lang._(
                        "binary mode doesn't take a newline argument"))
            elif buffering == 0:
                raise ValueError(lang._("can't have unbuffered text I/O"))

            # Modes w and x involve both directory and FAT changes, hence we need
            # to mark the fs dirty; however re-writing (mode r+) only requires a
            # write-lock as the mere act of opening the file doesn't actually
            # re-write anything (yet). Mode a (append) *might* only need a write
            # lock, if the file already exists but we don't know that at this point
            # so we mark the fs dirty in that case too
            lock = (
                fs.lock.read if set(mode) & set('r+') == {'r'} else
                fs.mark_dirty() if set(mode) & set('awx') else
                fs.lock.write)
            with lock:
                if 'r' in mode:
                    self._must_exist()
                elif 'x' in mode:
                    self._must_not_exist()
                    mode = mode.replace('x', 'w')
                self._must_not_be_dir()

                # If self._entry is None at this point, we're creating a file so
                # get the containing index and make an appropriate DirectoryEntry
                if self._entry is None:
                    date, time, cs = encode_timestamp(dt.datetime.now(tz=fs.tz))
                    parent = self.parent
                    parent._must_exist()
                    parent._must_be_dir()
                    entry = DirectoryEntry(
                        # filename and ext of the entry will be ignored; generated
                        # SFN will be used instead
                        filename=b'\0' * 8, ext=b'\0' * 3,
                        # Set DOS "Archive" bit and nothing else
                        attr=0x20, attr2=0,
                        cdate=date, ctime=time, ctime_cs=cs,
                        mdate=date, mtime=time,
                        adate=date,
                        first_cluster_lo=0, first_cluster_hi=0, size=0)
                    parent._index[self.name] = entry
                    self._index = parent._index
                    # Re-get the entry to obtain the generated SFN; required so
                    # the entry we pass to FatFile (below) has a valid key
                    self._entry = parent._index[self.name]
                else:
                    self._refresh()

                # Sanity check the buffering parameter and create the underlying
                # FatFile instance with an appropriate mode
                if 'b' in mode:
                    if buffering == 1:
                        warnings.warn(
                            RuntimeWarning(
                                "line buffering (buffering=1) isn't supported in "
                                "binary mode, the default buffer size will be "
                                "used"))
                        buffering = -1
                    f = fs.open_entry(self._index, self._entry, mode)
                else:
                    line_buffering = buffering == 1
                    f = fs.open_entry(self._index, self._entry, mode + 'b')

            # Wrap the underlying FatFile instance in whatever's necessary to make
            # it text-mode / buffered; this is done outside the locked section as
            # it doesn't actually involve any I/O
            if buffering:
                if buffering in (-1, 1):
                    buffering = fs.clusters.size
                f = {
                    (True, False): io.BufferedReader,
                    (False, True): io.BufferedWriter,
                    (True, True):  io.BufferedRandom,
                }[(f.readable(), f.writable())](f, buffering)
            if 'b' not in mode:
                f = io.TextIOWrapper(
                    f, encoding=encoding, errors=errors, newline=newline,
                    line_buffering=line_buffering)
            return f

        def unlink(self, missing_ok=False):
            """
            Remove this file. If the path points to a directory, use :meth:`rmdir`
            instead.

            If *missing_ok* is :data:`False` (the default),
            :exc:`FileNotFoundError` is raised if the path does not exist. If
            *missing_ok* is :data:`True`, :exc:`FileNotFoundError` exceptions will
            be ignored (same behaviour as the POSIX ``rm -f`` command).
            """
            fs = self._get_fs()
            with fs.mark_dirty():
                try:
                    self._must_exist()
                except FileNotFoundError:
                    if missing_ok:
                        return
                    else:
                        raise
                self._must_not_be_dir()

                self._refresh()
                del self._index[self.name]
                for cluster in fs.fat.chain(get_cluster(self._entry, fs.fat_type)):
                    fs.fat.mark_free(cluster)
            self._index = None
            self._entry = None

        def rename(self, target):
            """
            Rename this file or directory to the given *target*, and return a new
            :class:`FatPath` instance pointing to target. If *target* exists and is
            a file, it will be replaced silently. *target* can be either a string
            or another path object::

                >>> p = fs.root / 'foo'
                >>> p.open('w').write('some text')
                9
                >>> target = fs.root / 'bar'
                >>> p.rename(target)
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/bar')
                >>> target.read_text()
                'some text'

            The target path must be absolute. There are no guarantees of atomic
            behaviour (in contrast to :func:`os.rename`).

            .. note::

                :meth:`pathlib.Path.rename` permits relative paths, but interprets
                them relative to the working directory which is a concept
                :class:`FatPath` does not support.
            """
            fs = self._get_fs()
            if not isinstance(target, FatPath):
                target = FatPath(fs, target)
            target_fs = target._get_fs()
            if fs is not target_fs:
                raise ValueError(lang._(
                    'Cannot rename between FatFileSystem instances'))

            with fs.mark_dirty():
                if target.exists():
                    target._must_not_be_dir()
                    target._refresh()
                    target_cluster = get_cluster(target._entry, fs.fat_type)
                else:
                    target.touch()
                    target_cluster = 0
                self._refresh()
                target._index[target.name] = self._entry
                del self._index[self.name]
                if target_cluster:
                    for cluster in fs.fat.chain(target_cluster):
                        fs.fat.mark_free(cluster)
                self._index = None
                self._entry = None
                # The entry is mutated by the FatDirectory when setting
                # (specifically the SFN is re-written); in case the *target* is not
                # ephemeral, update its internal _entry to match to mutated one
                target._entry = target._index[target.name]
            return target

        def mkdir(self, mode=0o777, parents=False, exist_ok=False):
            """
            Create a new directory at this given path. The *mode* parameter exists
            only for compatibility with :class:`pathlib.Path` and is otherwise
            ignored. If the path already exists, :exc:`FileExistsError` is raised.

            If *parents* is true, any missing parents of this path are created as
            needed.

            If *parents* is false (the default), a missing parent raises
            :exc:`FileNotFoundError`.

            If *exist_ok* is false (the default), :exc:`FileExistsError` is raised
            if the target directory already exists.

            If *exist_ok* is true, :exc:`FileExistsError` exceptions will be
            ignored (same behavior as the POSIX ``mkdir -p`` command), but only if
            the last path component is not an existing non-directory file.
            """
            fs = self._get_fs()
            with fs.mark_dirty():
                try:
                    self._must_not_exist()
                except FileExistsError:
                    if exist_ok and self.is_dir():
                        return
                    else:
                        raise
                parent = self.parent
                try:
                    parent._must_exist()
                except FileNotFoundError:
                    if parents:
                        parent.mkdir(mode, parents, exist_ok)
                    else:
                        raise
                parent._must_be_dir()

                date, time, cs = encode_timestamp(dt.datetime.now(tz=fs.tz))
                cluster = next(fs.fat.free())
                fs.fat.mark_end(cluster)

                entry = DirectoryEntry(
                    # filename and ext of the entry will be ignored and overwritten
                    # with SFN generated from the associated name
                    filename=b'\0' * 8, ext=b'\0' * 3,
                    # Set DOS "Directory" bit and nothing else
                    attr=0x10, attr2=0,
                    cdate=date, ctime=time, ctime_cs=cs,
                    mdate=date, mtime=time,
                    adate=date,
                    first_cluster_lo=cluster & 0xFFFF,
                    first_cluster_hi=
                        cluster >> 16 if fs.fat_type == 'fat32' else 0,
                    size=0)
                parent._index[self.name] = entry
                self._index = fs.open_dir(cluster)
                self._entry = entry

                # Write the minimum entries that all sub-dirs must have: the "."
                # and ".." entries, and a terminal EOF entry
                # TODO: Where are we setting the initial EOF? What happens if the
                # cluster isn't zeroed?
                self._index['.'] = self._entry
                if parent._entry is None:
                    # Parent is the root
                    self._index['..'] = self._entry._replace(
                        first_cluster_hi=0, first_cluster_lo=0)
                else:
                    self._index['..'] = parent._entry

        def rmdir(self):
            """
            Remove this directory. The directory must be empty.
            """
            fs = self._get_fs()
            with fs.mark_dirty():
                self._must_exist()
                self._must_be_dir()
                if self._entry is not None:
                    cluster = get_cluster(self._entry, fs.fat_type)
                else:
                    cluster = 0
                if cluster == 0:
                    raise OSError(errno.EACCES, lang._(
                        'Cannot remove the root directory'))
                for item in self.iterdir():
                    raise OSError(errno.ENOTEMPTY, os.strerror(errno.ENOTEMPTY))

                parent = self.resolve(strict=False).parent
                # NOTE: We already know parent must exist and be a dir
                parent._resolve()
                del parent._index[self.name]
                for cluster in fs.fat.chain(cluster):
                    fs.fat.mark_free(cluster)
            self._index = None
            self._entry = None

        def resolve(self, strict=False):
            """
            Make the path absolute, resolving any symlinks. A new :class:`FatPath`
            object is returned.

            ``".."`` components are also eliminated (this is the only method to do
            so). If the path doesn't exist and *strict* is :data:`True`,
            :exc:`FileNotFoundError` is raised. If *strict* is :data:`False`, the
            path is resolved as far as possible and any remainder is appended
            without checking whether it exists.

            Note that as there is no concept of the "current" directory within
            :class:`~nobodd.fs.FatFileSystem`, relative paths cannot be resolved
            by this function, only absolute.
            """
            fs = self._get_fs()
            if not self.is_absolute():
                raise ValueError(lang._(
                    'Cannot resolve relative path {self!r}'.format(self=self)))
            parts = [p for p in self._parts if p != '.']
            while '..' in parts:
                i = parts.index('..')
                if i == 1:
                    del parts[1]
                else:
                    del parts[i - 1:i]
            result = FatPath(fs, *parts)
            if strict:
                result._must_exist()
            return result

        def iterdir(self):
            """
            When the path points to a directory, yield path objects of the
            directory contents::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> for child in fs.root.iterdir(): child
                ...
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/foo')
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/bar.txt')
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/setup.cfg')
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/baz')
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/adir')
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/BDIR')

            The children are yielded in arbitrary order (the order they are found
            in the file-system), and the special entries ``'.'`` and ``'..'`` are
            not included.
            """
            fs = self._get_fs()
            with fs.lock.read:
                self._must_exist()
                self._must_be_dir()
                for name, entry in self._index.items():
                    if name not in ('.', '..'):
                        yield FatPath._from_entry(
                            fs, self._index, entry, str(self / name))

        def match(self, pattern):
            """
            Match this path against the provided glob-style pattern. Returns
            a :class:`bool` indicating if the match is successful.

            If *pattern* is relative, the path may be either relative or absolute,
            and matching is done from the right::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> f = fs / 'nobodd' / 'mbr.py'
                >>> f
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/mbr.py')
                >>> f.match('*.py')
                True
                >>> f.match('nobodd/*.py')
                True
                >>> f.match('/*.py')
                False

            As FAT file-systems are case-insensitive, all matches are likewise
            case-insensitive.
            """
            if not pattern:
                raise ValueError(lang._('empty pattern'))
            pat_parts = get_parts(pattern.lower())
            parts = self.parts
            if len(pat_parts) > len(parts):
                return False
            for part, pat in zip(reversed(parts), reversed(pat_parts)):
                if not fnmatch.fnmatchcase(part.lower(), pat):
                    return False
            return True

        def _search(self, parent, parts):
            """
            Internal generator function for the implementation of :meth:`glob` and
            :meth:`rglob`. Called with *parent*, the containing :class:`FatPath`,
            and *parts*, the sequence of path components (in the form of strings)
            to match against.
            """

            def recursive(parent, parts):
                yield from self._search(parent, parts)
                for path in parent.iterdir():
                    if path.is_dir():
                        yield from recursive(path, parts)

            def wildcard(parent, part, parts):
                part_re = re.compile(fnmatch.translate(part), re.IGNORECASE)
                for path in parent.iterdir():
                    if part_re.match(path.name):
                        yield from self._search(path, parts)

            def precise(parent, part, parts):
                path = parent / part.lower()
                if path.exists():
                    yield from self._search(path, parts)

            if not parts:
                yield parent
            elif parent.is_dir():
                part, *parts = parts
                if part == '**':
                    yielded = set()
                    for path in recursive(parent, parts):
                        if path._parts not in yielded:
                            yielded.add(path._parts)
                            yield path
                elif '**' in part:
                    raise ValueError(lang._(
                        'invalid pattern: ** can only be an entire component'))
                elif '*' in part or '?' in part or '[' in part:
                    yield from wildcard(parent, part, parts)
                else:
                    yield from precise(parent, part, parts)

        def glob(self, pattern):
            """
            Glob the given relative *pattern* in the directory represented by this
            path, yielding matching files (of any kind)::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> sorted((fs.root / 'nobodd').glob('*.py'))
                [FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/__init__.py'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/disk.py'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/fat.py'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/fs.py'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/gpt.py'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/main.py'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/mbr.py'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/tftp.py'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/tools.py')]

            Patterns are the same as for :func:`~fnmatch.fnmatch`, with the
            addition of "``**``" which means "this directory and all
            subdirectories, recursively". In other words, it enables recurisve
            globbing.

            .. warning::

                Using the "``**``" pattern in large directory trees may consume an
                inordinate amount of time.
            """
            if not pattern:
                raise ValueError(lang._('Unacceptable pattern'))
            pat_parts = get_parts(pattern)
            if pat_parts[:1] == ('',):
                raise ValueError(lang._('Non-relative patterns are not supported'))
            fs = self._get_fs()
            with fs.lock.read:
                self._must_exist()
                yield from self._search(self, pat_parts)

        def rglob(self, pattern):
            """
            This is like calling :meth:`glob` with a prefix of "``**/``" to the
            specified *pattern*.
            """
            if not pattern:
                raise ValueError(lang._('Unacceptable pattern'))
            pat_parts = get_parts(pattern)
            if pat_parts[:1] == ('',):
                raise ValueError(lang._('Non-relative patterns are not supported'))
            fs = self._get_fs()
            with fs.lock.read:
                self._must_exist()
                yield from self._search(self, ('**',) + pat_parts)

        def stat(self, *, follow_symlinks=True):
            """
            Return a :class:`os.stat_result` object containing information about
            this path::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> p = (fs.root / 'nobodd' / 'main.py')
                >>> p.stat().st_size
                388
                >>> p.stat().st_ctime
                1696606672.02

            .. note::

                In a FAT file-system, ``atime`` has day resolution, ``mtime`` has
                2-second resolution, and ``ctime`` has either 2-second or
                millisecond resolution depending on the driver that created it.
                Directories have no timestamp information.

            The *follow_symlinks* parameter is included purely for compatibility
            with :meth:`pathlib.Path.stat`; it is ignored as symlinks are not
            supported.
            """
            fs = self._get_fs()
            self._must_exist()
            if self._entry is not None and not (self._entry.attr & 0x10):
                self._refresh()
                return os.stat_result((
                    stat.S_IFREG | (0o666, 0o444)[fs.readonly], # mode
                    get_cluster(self._entry, fs.fat_type),      # inode
                    id(fs),                                     # dev
                    1,                                          # nlink
                    0,                                          # uid
                    0,                                          # gid
                    self._entry.size,                           # size
                    decode_timestamp(                           # atime
                        self._entry.adate, 0, tz=fs.tz).timestamp(),
                    decode_timestamp(                           # mtime
                        self._entry.mdate, self._entry.mtime, tz=fs.tz).timestamp(),
                    decode_timestamp(                           # ctime
                        self._entry.cdate, self._entry.ctime,
                        self._entry.ctime_cs, fs.tz).timestamp()))
            else: # self._index is not None is guaranteed by _must_exist
                # NOTE: No need to _refresh as the cluster of a sub-directory can
                # never change
                return os.stat_result((
                    stat.S_IFDIR | (0o777, 0o555)[fs.readonly], # mode
                    self._index.cluster,                        # inode
                    id(fs),                                     # dev
                    2,                                          # nlink
                    0,                                          # uid
                    0,                                          # gid
                    0,                                          # size
                    0,                                          # atime
                    0,                                          # mtime
                    0))                                         # ctime

        @property
        def fs(self):
            """
            Returns the :class:`~nobodd.fs.FatFileSystem` instance that this
            instance was constructed with.
            """
            return self._get_fs()

        @property
        def root(self):
            """
            Returns a string representing the root. This is always "/".
            """
            return self.sep

        @property
        def anchor(self):
            """
            Returns the concatenation of the drive and root. This is always "/".
            """
            return self.sep

        @property
        def name(self):
            """
            A string representing the final path component, excluding the root::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> p = (fs.root / 'nobodd' / 'main.py')
                >>> p.name
                'main.py'
            """
            try:
                return self._parts[-1]
            except IndexError:
                return ''

        @property
        def suffix(self):
            """
            The file extension of the final component, if any::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> p = (fs.root / 'nobodd' / 'main.py')
                >>> p.suffix
                '.py'
            """
            name = self.name
            try:
                return name[name.rindex('.'):]
            except ValueError:
                return ''

        @property
        def suffixes(self):
            """
            A list of the path's file extensions::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> p = (fs.root / 'nobodd.tar.gz')
                >>> p.suffixes
                ['.tar', '.gz']
            """
            name = self.name
            return ['.' + s for s in name.lstrip('.').split('.')[1:]]

        @property
        def stem(self):
            """
            The final path component, without its suffix::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> p = (fs.root / 'nobodd' / 'main.py')
                >>> p.stem
                'main'
            """
            name = self.name
            try:
                return name[:name.rindex('.')]
            except ValueError:
                return name

        @property
        def parts(self):
            """
            A tuple giving access to the path's various components::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> p = (fs.root / 'nobodd' / 'main.py')
                >>> p.parts
                ['/', 'nobodd', 'main.py']
            """
            return tuple(
                self.sep if index == 0 and part == '' else part
                for index, part in enumerate(self._parts))

        @property
        def parent(self):
            """
            The logical parent of the path::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> p = (fs.root / 'nobodd' / 'main.py')
                >>> p.parent
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd')

            You cannot go past an anchor::

                >>> p = (fs.root / 'nobodd' / 'main.py')
                >>> p.parent.parent.parent
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/')
            """
            fs = self._get_fs()
            if len(self._parts) > 1:
                return type(self)(fs, *self._parts[:-1])
            elif self._parts == ('',) or self._parts == ('.',):
                return self
            else:
                return type(self)(fs, '.')

        @property
        def parents(self):
            """
            An immutable sequence providing access to the logical ancestors of the
            path::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> p = (fs.root / 'nobodd' / 'main.py')
                >>> p.parents
                (FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd'),
                 FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/'))
            """
            result = [self.parent]
            self = result[-1]
            while self.parent is not self:
                result.append(self.parent)
                self = result[-1]
            return tuple(result)

        def read_text(self, encoding=None, errors=None):
            """
            Return the decoded contents of the pointed-to file as a string::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> (fs.root / 'foo').read_text()
                'foo\\n'
            """
            fs = self._get_fs()
            with fs.lock.read:
                with self.open(encoding=encoding, errors=errors) as f:
                    return f.read()

        def write_text(self, data, encoding=None, errors=None, newline=None):
            """
            Open the file pointed to in text mode, write *data* to it, and close
            the file::

                >>> p = fs.root / 'my_text_file'
                >>> p.write_text('Text file contents')
                18
                >>> p.read_text()
                'Text file contents'

            An existing file of the same name is overwritten. The optional
            parameters have the same meaning as in :meth:`open`.
            """
            fs = self._get_fs()
            with fs.mark_dirty():
                with self.open(mode='w', encoding=encoding, errors=errors,
                               newline=newline) as f:
                    return f.write(data)

        def read_bytes(self):
            """
            Return the binary contents of the pointed-to file as a bytes object::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> (fs.root / 'foo').read_text()
                b'foo\\n'
            """
            fs = self._get_fs()
            with fs.lock.read:
                with self.open(mode='rb') as f:
                    return f.read()

        def write_bytes(self, data):
            """
            Open the file pointed to in bytes mode, write *data* to it, and close
            the file::

                >>> p = fs.root / 'my_binary_file'
                >>> p.write_bytes(b'Binary file contents')
                20
                >>> p.read_bytes()
                b'Binary file contents'

            An existing file of the same name is overwritten.
            """
            fs = self._get_fs()
            with fs.mark_dirty():
                with self.open(mode='wb') as f:
                    return f.write(data)

        def touch(self, mode=0o666, exist_ok=True):
            """
            Create a file at this given path. The *mode* parameter is only present
            for compatibility with :class:`pathlib.Path` and is otherwise ignored.
            If the file already exists, the function succeeds if *exist_ok* is
            :data:`True` (and its modification time is updated to the current
            time), otherwise :exc:`FileExistsError` is raised.
            """
            fs = self._get_fs()
            with fs.mark_dirty():
                if exist_ok:
                    with self.open('ab', buffering=0) as f:
                        f._set_mtime()
                else:
                    with self.open('xb', buffering=0) as f:
                        pass

        def exists(self):
            """
            Whether the path points to an existing file or directory::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> (fs.root / 'foo').exists()
                True
                >>> (fs.root / 'fooo').exists()
                False
            """
            self._resolve()
            return self._index is not None and (
                self._entry is not None or self._parts == ('',))

        def is_dir(self):
            """
            Return a :class:`bool` indicating whether the path points to a
            directory. :data:`False` is also returned if the path doesn't exist.
            """
            self._resolve()
            return self._index is not None and (
                self._entry is None or bool(self._entry.attr & 0x10))

        def is_file(self):
            """
            Returns a :class:`bool` indicating whether the path points to a regular
            file. :data:`False` is also returned if the path doesn't exist.
            """
            self._resolve()
            return self._entry is not None and not (self._entry.attr & 0x10)

        def is_mount(self):
            """
            Returns a :class:`bool` indicating whether the path is a *mount point*.
            In this implementation, this is only :data:`True` for the root path.
            """
            return self._parts == ('',)

        def is_absolute(self):
            """
            Return whether the path is absolute or not. A path is considered
            absolute if it has a "/" prefix.
            """
            return self._parts[:1] == ('',)

        def is_relative_to(self, *other):
            """
            Return whether or not this path is relative to the *other* path.
            """
            try:
                self.relative_to(*other)
            except ValueError:
                return False
            else:
                return True

        def relative_to(self, *other):
            """
            Compute a version of this path relative to the path represented by
            *other*. If it's impossible, :exc:`ValueError` is raised.
            """
            if not other:
                raise TypeError(lang._('need at least one argument'))
            fs = self._get_fs()
            to = type(self)(fs, *other)
            n = len(to._parts)
            if self._parts[:n] != to._parts:
                raise ValueError(lang._(
                    '{self!r} is not in the subpath of {to!r} OR one path is '
                    'relative and the other is absolute'
                    .format(self=self, to=to)))
            return type(self)(fs, *self._parts[n:])

        def joinpath(self, *other):
            """
            Calling this method is equivalent to combining the path with each of
            the *other* arguments in turn::

                >>> fs
                <FatFileSystem label='TEST' fat_type='fat16'>
                >>> fs.root
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/')
                >>> fs.root.joinpath('nobodd')
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd')
                >>> fs.root.joinpath('nobodd', 'main.py')
                FatPath(<FatFileSystem label='TEST' fat_type='fat16'>, '/nobodd/main.py')
            """
            fs = self._get_fs()
            other = get_parts(*other)
            if other[:1] == ('',):
                return type(self)(fs, *other)
            else:
                return type(self)(fs, *self._parts, *other)

        def with_name(self, name):
            """
            Return a new path with the :attr:`name` changed. If the original path
            doesn't have a name, :exc:`ValueError` is raised.
            """
            fs = self._get_fs()
            if not self.name:
                raise ValueError(lang._(
                    '{self!r} has an empty name'.format(self=self)))
            if not name:
                raise ValueError(lang._(
                    'invalid name {name!r}'.format(name=name)))
            return type(self)(fs, *self._parts[:-1], name)

        def with_stem(self, stem):
            """
            Return a new path with the :attr:`stem` changed. If the original path
            doesn't have a name, :exc:`ValueError` is raised.
            """
            return self.with_name(stem + self.suffix)

        def with_suffix(self, suffix):
            """
            Return a new path with the :attr:`suffix` changed. If the original path
            doesn't have a suffix, the new *suffix* is appended instead. If the
            *suffix* is an empty string, the original suffix is removed.
            """
            if self.sep in suffix:
                raise ValueError(lang._(
                    'Invalid suffix {suffix!r}'.format(suffix=suffix)))
            if suffix and not suffix.startswith('.') or suffix == '.':
                raise ValueError(lang._(
                    'Invalid suffix {suffix!r}'.format(suffix=suffix)))
            name = self.name
            old_suffix = self.suffix
            if not old_suffix:
                name = name + suffix
            else:
                name = name[:-len(old_suffix)] + suffix
            return self.with_name(name)

        __truediv__ = joinpath

        def __eq__(self, other):
            if not isinstance(other, FatPath):
                return NotImplemented
            self_fs = self._get_fs()
            other_fs = other._get_fs()
            if self_fs is not other_fs:
                raise TypeError(lang._(
                    'comparison is not supported between instances of '
                    '{self.__class__.__name__} with different file-systems'
                    .format(self=self)))
            return len(self._parts) == len(other._parts) and all(
                sp.lower() == op.lower()
                for sp, op in zip_longest(self._parts, other._parts, fillvalue=''))

        def __le__(self, other):
            if not isinstance(other, FatPath):
                return NotImplemented
            self_fs = self._get_fs()
            other_fs = other._get_fs()
            if self_fs is not other_fs:
                raise TypeError(lang._(
                    'comparison is not supported between instances of '
                    '{self.__class__.__name__} with different file-systems'
                    .format(self=self)))
            return all(
                sp.lower() <= op.lower()
                for sp, op in zip_longest(self.parts, other.parts, fillvalue=''))

        def __ne__(self, other):
            if not isinstance(other, FatPath):
                return NotImplemented
            return not self.__eq__(other)

        def __lt__(self, other):
            if not isinstance(other, FatPath):
                return NotImplemented
            return self.__le__(other) and not self.__eq__(other)

        def __gt__(self, other):
            if not isinstance(other, FatPath):
                return NotImplemented
            return not self.__le__(other)

        def __ge__(self, other):
            if not isinstance(other, FatPath):
                return NotImplemented
            return self.__eq__(other) or self.__gt__(other)

    def get_cluster(entry, fat_type):
        """
        Given *entry*, a :class:`~nobodd.fat.DirectoryEntry`, and the *fat_type*
        indicating the size of FAT clusters, return the first cluster of the file
        associated with the directory entry.
        """
        return entry.first_cluster_lo | (
            entry.first_cluster_hi << 16 if fat_type == 'fat32' else 0)

    def get_parts(*pathsegments):
        """
        Given *pathsegments*, split them on the "/" path separator, and return a
        :class:`tuple` containing each path segment.

        If the path segments refer to an absolute path (beginning with "/") the
        first element of the returned :class:`tuple` will be an empty string.
        """
        return tuple(
            part
            for i1, segment in enumerate(pathsegments)
            for i2, part in enumerate(str(segment).split('/'))
            if i1 == i2 == 0 or part)