# -*- coding: utf-8 -*-

import errno
import grp
import os
import stat
import sys
from unittest import mock

import pytest

from snakeoil import osutils
from snakeoil._internals import deprecated
from snakeoil.osutils import listdir_dirs, listdir_files, sizeof_fmt, supported_systems
from snakeoil.osutils.mount import MS_BIND, mount, umount


class ReaddirCommon:
    @pytest.fixture
    def subdir(self, tmp_path):
        (subdir := tmp_path / "dir").mkdir()
        (tmp_path / "file").touch()
        os.mkfifo((tmp_path / "fifo"))
        return subdir

    def _test_missing(self, tmp_path, funcs):
        for func in funcs:
            pytest.raises(OSError, func, tmp_path / "spork")


class TestNativeListDir(ReaddirCommon):
    def test_listdir_dirs(self, tmp_path, subdir):
        assert listdir_dirs(tmp_path) == ["dir"]
        assert listdir_dirs(subdir) == []

    def test_listdir_files(self, tmp_path, subdir):
        assert listdir_files(tmp_path) == ["file"]
        assert listdir_dirs(subdir) == []

    def test_missing(self, tmp_path, subdir):
        return self._test_missing(
            tmp_path,
            (
                listdir_dirs,
                listdir_files,
            ),
        )

    def test_dangling_sym(self, tmp_path, subdir):
        (tmp_path / "monkeys").symlink_to("foon")
        assert listdir_files(tmp_path) == ["file"]


class TestEnsureDirs:
    def check_dir(self, path, uid, gid, mode):
        assert path.is_dir()
        st = os.stat(path)
        assert stat.S_IMODE(st.st_mode) == mode, "0%o != 0%o" % (
            stat.S_IMODE(st.st_mode),
            mode,
        )
        assert st.st_uid == uid
        assert st.st_gid == gid

    def test_ensure_dirs(self, tmp_path):
        # default settings
        path = tmp_path / "foo" / "bar"
        assert osutils.ensure_dirs(path)
        self.check_dir(path, os.geteuid(), os.getegid(), 0o777)

    def test_minimal_nonmodifying(self, tmp_path):
        path = tmp_path / "foo" / "bar"
        assert osutils.ensure_dirs(path, mode=0o755)
        os.chmod(path, 0o777)
        assert osutils.ensure_dirs(path, mode=0o755, minimal=True)
        self.check_dir(path, os.geteuid(), os.getegid(), 0o777)

    def test_minimal_modifying(self, tmp_path):
        path = tmp_path / "foo" / "bar"
        assert osutils.ensure_dirs(path, mode=0o750)
        assert osutils.ensure_dirs(path, mode=0o005, minimal=True)
        self.check_dir(path, os.geteuid(), os.getegid(), 0o755)

    def test_create_unwritable_subdir(self, tmp_path):
        path = tmp_path / "restricted" / "restricted"
        # create the subdirs without 020 first
        assert osutils.ensure_dirs(path.parent)
        assert osutils.ensure_dirs(path, mode=0o020)
        self.check_dir(path, os.geteuid(), os.getegid(), 0o020)
        # unrestrict it
        osutils.ensure_dirs(path)
        self.check_dir(path, os.geteuid(), os.getegid(), 0o777)

    def test_path_is_a_file(self, tmp_path):
        # fail if passed a path to an existing file
        (path := tmp_path / "file").touch()
        assert path.is_file()
        assert not osutils.ensure_dirs(path, mode=0o700)

    def test_non_dir_in_path(self, tmp_path):
        # fail if one of the parts of the path isn't a dir
        path = tmp_path / "file" / "dir"
        (tmp_path / "file").touch()
        assert not osutils.ensure_dirs(path, mode=0o700)

    def test_mkdir_failing(self, tmp_path):
        # fail if os.mkdir fails
        with mock.patch("snakeoil.osutils.os.mkdir") as mkdir:
            mkdir.side_effect = OSError(30, "Read-only file system")
            path = tmp_path / "dir"
            assert not osutils.ensure_dirs(path, mode=0o700)

            # force temp perms
            assert not osutils.ensure_dirs(path, mode=0o400)
            mkdir.side_effect = OSError(17, "File exists")
            assert not osutils.ensure_dirs(path, mode=0o700)

    def test_chmod_or_chown_failing(self, tmp_path):
        # fail if chmod or chown fails
        path = tmp_path / "dir"
        path.mkdir()
        path.chmod(0o750)

        with (
            mock.patch("snakeoil.osutils.os.chmod") as chmod,
            mock.patch("snakeoil.osutils.os.chown") as chown,
        ):
            chmod.side_effect = OSError(5, "Input/output error")

            # chmod failure when file exists and trying to reset perms to match
            # the specified mode
            assert not osutils.ensure_dirs(path, mode=0o005, minimal=True)
            assert not osutils.ensure_dirs(path, mode=0o005, minimal=False)
            path.rmdir()

            # chmod failure when resetting perms on parents
            assert not osutils.ensure_dirs(path, mode=0o400)
            path.rmdir()

            # chown failure when resetting perms on parents
            chmod.side_effect = None
            chown.side_effect = OSError(5, "Input/output error")
            assert not osutils.ensure_dirs(path, uid=1000, gid=1000, mode=0o400)

    def test_reset_sticky_parent_perms(self, tmp_path):
        # make sure perms are reset after traversing over sticky parents
        sticky_parent = tmp_path / "dir"
        path = sticky_parent / "dir"
        sticky_parent.mkdir()
        sticky_parent.chmod(0o2755)
        pre_sticky_parent = os.stat(sticky_parent)
        assert osutils.ensure_dirs(path, mode=0o700)
        post_sticky_parent = os.stat(sticky_parent)
        assert pre_sticky_parent.st_mode == post_sticky_parent.st_mode

    def test_mode(self, tmp_path):
        path = tmp_path / "mode" / "mode"
        assert osutils.ensure_dirs(path, mode=0o700)
        self.check_dir(path, os.geteuid(), os.getegid(), 0o700)
        # unrestrict it
        osutils.ensure_dirs(path)
        self.check_dir(path, os.geteuid(), os.getegid(), 0o777)

    def test_gid(self, tmp_path):
        # abuse the portage group as secondary group
        try:
            portage_gid = grp.getgrnam("portage").gr_gid
        except KeyError:
            pytest.skip("the portage group does not exist")
        if portage_gid not in os.getgroups():
            pytest.skip("you are not in the portage group")
        path = tmp_path / "group" / "group"
        assert osutils.ensure_dirs(path, gid=portage_gid)
        self.check_dir(path, os.geteuid(), portage_gid, 0o777)
        assert osutils.ensure_dirs(path)
        self.check_dir(path, os.geteuid(), portage_gid, 0o777)
        assert osutils.ensure_dirs(path, gid=os.getegid())
        self.check_dir(path, os.geteuid(), os.getegid(), 0o777)


class TestAbsSymlink:
    @deprecated.suppress_deprecations()
    def test_abssymlink(self, tmp_path):
        target = tmp_path / "target"
        linkname = tmp_path / "link"
        target.mkdir()
        linkname.symlink_to("target")
        assert osutils._abssymlink(linkname) == str(target)


@deprecated.suppress_deprecations()
class Test_Native_NormPath:
    func = staticmethod(osutils.normpath)

    def test_normpath(self):
        f = self.func

        def check(src, val):
            got = f(src)
            assert got == val, f"{src!r}: expected {val!r}, got {got!r}"

        check("/foo/", "/foo")
        check("//foo/", "/foo")
        check("//foo/.", "/foo")
        check("//..", "/")
        check("//..//foo", "/foo")
        check("/foo/..", "/")
        check("..//foo", "../foo")
        check("../foo/../", "..")
        check("../", "..")
        check("../foo/..", "..")
        check("../foo/../dar", "../dar")
        check(".//foo", "foo")
        check("/foo/../../", "/")
        check("/foo/../../..", "/")
        check("/tmp/foo/../dar/", "/tmp/dar")
        check("/tmp/foo/../dar", "/tmp/dar")

        # explicit unicode and bytes
        check("/tmṕ/föo//../dár", "/tmṕ/dár")
        check(
            b"/tm\xe1\xb9\x95/f\xc3\xb6o//../d\xc3\xa1r", b"/tm\xe1\xb9\x95/d\xc3\xa1r"
        )
        check("/föó/..", "/")
        check(b"/f\xc3\xb6\xc3\xb3/..", b"/")


class Test_unlink_if_exists:
    func = staticmethod(osutils.unlink_if_exists)

    def test_it(self, tmp_path):
        f = self.func
        path = tmp_path / "target"
        f(path)
        path.write_text("")
        f(path)
        assert not path.exists()
        # and once more for good measure...
        f(path)


class TestSupportedSystems:
    def test_supported_system(self):
        @supported_systems("supported")
        def func():
            return True

        with mock.patch("snakeoil.osutils.sys") as _sys:
            _sys.configure_mock(platform="supported")
            assert func()

    def test_unsupported_system(self):
        @supported_systems("unsupported")
        def func():
            return True

        with pytest.raises(NotImplementedError):
            func()

        # make sure we're iterating through the system params correctly
        with mock.patch("snakeoil.osutils.sys") as _sys:
            _sys.configure_mock(platform="u")
            with pytest.raises(NotImplementedError):
                func()

    def test_multiple_systems(self):
        @supported_systems("darwin", "linux")
        def func():
            return True

        with mock.patch("snakeoil.osutils.sys") as _sys:
            _sys.configure_mock(platform="nonexistent")
            with pytest.raises(NotImplementedError):
                func()

            for platform in ("linux2", "darwin"):
                _sys.configure_mock(platform=platform)
                assert func()


@pytest.mark.skipif(
    not sys.platform.startswith("linux"), reason="supported on Linux only"
)
class TestMount:
    @pytest.fixture
    def source(self, tmp_path):
        source = tmp_path / "source"
        source.mkdir()
        return source

    @pytest.fixture
    def target(self, tmp_path):
        target = tmp_path / "target"
        target.mkdir()
        return target

    def test_args_bytes(self):
        # The initial source, target, and fstype arguments to mount(2) must be
        # byte strings; if they are unicode strings the arguments get mangled
        # leading to errors when the syscall is run. This confirms mount() from
        # snakeoil.osutils always converts the arguments into byte strings.
        for source, target, fstype in (
            (b"source", b"target", b"fstype"),
            ("source", "target", "fstype"),
        ):
            with mock.patch("snakeoil.osutils.mount.ctypes") as mock_ctypes:
                with pytest.raises(OSError):
                    mount(str(source), str(target), fstype, MS_BIND)
                mount_call = next(
                    x for x in mock_ctypes.mock_calls if x[0] == "CDLL().mount"
                )
                for arg in mount_call[1][0:3]:
                    assert isinstance(arg, bytes)

    def test_missing_dirs(self):
        with pytest.raises(OSError) as cm:
            mount("source", "target", None, MS_BIND)
        assert cm.value.errno in (errno.EPERM, errno.ENOENT)

    @pytest.mark.skipif(os.getuid() == 0, reason="this test must be run as non-root")
    def test_no_perms(self, source, target):
        with pytest.raises(OSError) as cm:
            mount(str(source), str(target), None, MS_BIND)
        assert cm.value.errno in (errno.EPERM, errno.EACCES)
        with pytest.raises(OSError) as cm:
            umount(str(target))
        assert cm.value.errno in (errno.EPERM, errno.EINVAL)

    @pytest.mark.skip("test must be rewritten due to reliance on SplitExec")
    def test_bind_mount(self): ...

    @pytest.mark.skip("test must be rewritten due to reliance on SplitExec")
    def test_lazy_unmount(self): ...


class TestSizeofFmt:
    expected = {
        0: ("0.0 B", "0.0 B"),
        1: ("1.0 B", "1.0 B"),
        1000: ("1.0 kB", "1000.0 B"),
        1024: ("1.0 kB", "1.0 KiB"),
        1000**2: ("1.0 MB", "976.6 KiB"),
        1024**2: ("1.0 MB", "1.0 MiB"),
        1000**3: ("1.0 GB", "953.7 MiB"),
        1024**3: ("1.1 GB", "1.0 GiB"),
        1000**8: ("1.0 YB", "847.0 ZiB"),
        1024**8: ("1.2 YB", "1.0 YiB"),
        1000**9: ("1000.0 YB", "827.2 YiB"),
        1024**9: ("1237.9 YB", "1024.0 YiB"),
    }

    @pytest.mark.parametrize("binary", (False, True))
    @pytest.mark.parametrize("size", sorted(expected))
    def test_sizeof_fmt(self, size, binary):
        assert sizeof_fmt(size, binary=binary) == self.expected[size][binary]
