from __future__ import annotations

__lazy_modules__ = {
    "contextlib",
    "glob",
    "grp",
    "plumbum.lib",
    "plumbum.path.remote",
    "pwd",
    "shutil",
    "urllib",
    "urllib.parse",
    "urllib.request",
}

import errno
import glob
import logging
import os
import shutil
import sys
import urllib.parse as urlparse
import urllib.request as urllib
from contextlib import contextmanager
from typing import IO, TYPE_CHECKING

from plumbum.lib import IS_WIN32
from plumbum.path.base import FSUser, Path
from plumbum.path.remote import RemotePath

if TYPE_CHECKING:
    import builtins
    from collections.abc import Generator, Iterator

    from plumbum._compat.typing import Self

if not sys.platform.startswith("win32"):
    from grp import getgrgid, getgrnam
    from pwd import getpwnam, getpwuid
else:

    def getpwuid(_x: int) -> tuple[None]:
        return (None,)

    def getgrgid(_: int) -> tuple[None]:
        return (None,)

    def getpwnam(_x: str) -> None:  # not NoReturn to avoid unreachable warnings
        raise OSError("`getpwnam` not supported")

    def getgrnam(_x: str) -> None:  # not NoReturn to avoid unreachable warnings
        raise OSError("`getgrnam` not supported")


logger = logging.getLogger("plumbum.local")

_EMPTY = object()


# ===================================================================================================
# Local Paths
# ===================================================================================================
class LocalPath(Path):
    """The class implementing local-machine paths"""

    __slots__ = ()

    CASE_SENSITIVE = not IS_WIN32

    def __new__(cls, *parts: str | Path) -> Self:
        if (
            len(parts) == 1
            and isinstance(parts[0], cls)
            and not isinstance(parts[0], LocalWorkdir)
        ):
            return parts[0]
        if not parts:
            raise TypeError("At least one path part is required (none given)")
        if any(isinstance(path, RemotePath) for path in parts):
            raise TypeError(f"LocalPath cannot be constructed from {parts!r}")
        return super().__new__(
            cls, os.path.normpath(os.path.join(*(str(p) for p in parts)))
        )

    @property
    def _path(self) -> str:
        return str(self)

    def _get_info(self) -> str:
        return self._path

    def _form(self, *parts: str | Path) -> LocalPath:
        return LocalPath(*parts)

    @property
    def name(self) -> str:
        return os.path.basename(str(self))

    @property
    def dirname(self) -> LocalPath:
        return LocalPath(os.path.dirname(str(self)))

    @property
    def suffix(self) -> str:
        return os.path.splitext(str(self))[1]

    @property
    def suffixes(self) -> list[str]:
        exts = []
        base = str(self)
        while True:
            base, ext = os.path.splitext(base)
            if ext:
                exts.append(ext)
            else:
                return list(reversed(exts))

    @property
    def uid(self) -> FSUser:
        uid = self.stat().st_uid
        name = getpwuid(uid)[0]
        return FSUser(uid, name)

    @property
    def gid(self) -> FSUser:
        gid = self.stat().st_gid
        name = getgrgid(gid)[0]
        return FSUser(gid, name)

    def join(self, *others: str) -> LocalPath:  # type: ignore[override]
        return LocalPath(self, *others)

    def list(self) -> list[LocalPath]:
        return [self / fn for fn in os.listdir(str(self))]

    def iterdir(self) -> Iterator[LocalPath]:
        try:
            return (self / fn.name for fn in os.scandir(str(self)))
        except AttributeError:
            return (self / fn for fn in os.listdir(str(self)))

    def is_dir(self) -> bool:
        return os.path.isdir(str(self))

    def is_file(self) -> bool:
        return os.path.isfile(str(self))

    def is_symlink(self) -> bool:
        return os.path.islink(str(self))

    def exists(self) -> bool:
        return os.path.exists(str(self))

    def stat(self) -> os.stat_result:
        return os.stat(str(self))

    def with_name(self, name: str) -> LocalPath:
        return LocalPath(self.dirname) / name

    @property
    def stem(self) -> str:
        return os.path.splitext(self.name)[0]

    def with_suffix(self, suffix: str, depth: int | None = 1) -> LocalPath:
        if (
            suffix and not suffix.startswith(os.path.extsep)
        ) or suffix == os.path.extsep:
            raise ValueError(f"Invalid suffix {suffix!r}")
        name = self.name
        depth = len(self.suffixes) if depth is None else min(depth, len(self.suffixes))
        for _ in range(depth):
            name, _ = os.path.splitext(name)
        return LocalPath(self.dirname) / (name + suffix)

    def glob(self, pattern: str) -> builtins.list[LocalPath]:
        return self._glob(
            pattern,
            lambda pat: [
                LocalPath(m)
                for m in glob.glob(
                    os.path.join(glob.escape(str(self)), pat), recursive=True
                )
            ],
        )

    def delete(self) -> None:
        if not self.exists():
            return
        if self.is_dir():
            shutil.rmtree(str(self))
        else:
            try:
                os.remove(str(self))
            except OSError as ex:  # pragma: no cover
                # file might already been removed (a race with other threads/processes)
                if ex.errno != errno.ENOENT:
                    raise

    def move(self, dst: LocalPath | str) -> LocalPath:
        if isinstance(dst, RemotePath):
            raise TypeError(f"Cannot move local path {self} to {dst!r}")
        shutil.move(str(self), str(dst))
        return LocalPath(dst)

    def copy(self, dst: LocalPath | str, override: bool | None = None) -> LocalPath:
        if isinstance(dst, RemotePath):
            raise TypeError(f"Cannot copy local path {self} to {dst!r}")
        dst = LocalPath(dst)
        if override is False and dst.exists():
            raise TypeError("File exists and override was not specified")
        if override:
            dst.delete()
        if self.is_dir():
            shutil.copytree(str(self), str(dst))
        else:
            dst_dir = LocalPath(dst).dirname
            if not dst_dir.exists():
                dst_dir.mkdir()
            shutil.copy2(str(self), str(dst))
        return dst

    def mkdir(
        self, mode: int = 0o777, parents: bool = True, exist_ok: bool = True
    ) -> None:
        if not self.exists() or not exist_ok:
            try:
                if parents:
                    os.makedirs(str(self), mode)
                else:
                    os.mkdir(str(self), mode)
            except OSError as ex:  # pragma: no cover
                # directory might already exist (a race with other threads/processes)
                if ex.errno != errno.EEXIST or not exist_ok:
                    raise

    def open(self, mode: str = "r", encoding: str | None = None) -> IO[str] | IO[bytes]:
        return open(
            str(self),
            mode,
            encoding=encoding,
        )

    def read(self, encoding: str | None = None, mode: str = "r") -> str | bytes:
        if encoding and "b" not in mode:
            mode = mode + "b"
        with self.open(mode) as f:
            data = f.read()
            if encoding:
                assert isinstance(data, bytes)
                return data.decode(encoding)
            return data

    def write(
        self, data: str | bytes, encoding: str | None = None, mode: str | None = None
    ) -> None:
        if encoding:
            if isinstance(data, bytes):
                msg = "Must write string data if encoding given"
                raise TypeError(msg)
            data = data.encode(encoding)
        if mode is None:
            mode = "w" if isinstance(data, str) else "wb"
        with self.open(mode) as f:
            f.write(data)  # type: ignore[arg-type]

    def touch(self) -> None:
        with open(str(self), "a", encoding="utf-8"):
            os.utime(str(self), None)

    def chown(
        self,
        owner: int | str | None = None,
        group: int | str | None = None,
        recursive: bool | None = None,
    ) -> None:
        if not hasattr(os, "chown"):
            raise OSError("os.chown() not supported")
        uid = (
            self.uid
            if owner is None
            else (owner if isinstance(owner, int) else getpwnam(owner)[2])
        )
        gid = (
            self.gid
            if group is None
            else (group if isinstance(group, int) else getgrnam(group)[2])
        )
        os.chown(str(self), uid, gid)
        if recursive or (recursive is None and self.is_dir()):
            for subpath in self.walk():
                os.chown(str(subpath), uid, gid)

    def chmod(self, mode: int) -> None:
        if not hasattr(os, "chmod"):
            raise OSError("os.chmod() not supported")
        os.chmod(str(self), mode)

    def access(self, mode: int | str = 0) -> bool:
        return os.access(str(self), self._access_mode_to_flags(mode))

    def link(self, dst: LocalPath | str) -> None:
        if isinstance(dst, RemotePath):
            raise TypeError(
                f"Cannot create a hardlink from local path {self} to {dst!r}"
            )
        if hasattr(os, "link"):
            os.link(str(self), str(dst))
        else:
            from plumbum.machines.local import local

            # windows: use mklink
            if self.is_dir():
                local["cmd"]("/C", "mklink", "/D", "/H", str(dst), str(self))
            else:
                local["cmd"]("/C", "mklink", "/H", str(dst), str(self))

    def symlink(self, dst: LocalPath | str) -> None:
        if isinstance(dst, RemotePath):
            raise TypeError(
                f"Cannot create a symlink from local path {self} to {dst!r}"
            )
        if hasattr(os, "symlink"):
            os.symlink(str(self), str(dst))
        else:
            from plumbum.machines.local import local

            # windows: use mklink
            if self.is_dir():
                local["cmd"]("/C", "mklink", "/D", str(dst), str(self))
            else:
                local["cmd"]("/C", "mklink", str(dst), str(self))

    def unlink(self) -> None:
        try:
            if hasattr(os, "symlink") or not self.is_dir():
                os.unlink(str(self))
            else:
                # windows: use rmdir for directories and directory symlinks
                os.rmdir(str(self))
        except OSError as ex:  # pragma: no cover
            # file might already been removed (a race with other threads/processes)
            if ex.errno != errno.ENOENT:
                raise

    def as_uri(self, scheme: str = "file") -> str:
        return urlparse.urljoin(f"{scheme}://", urllib.pathname2url(str(self)))

    @property
    def drive(self) -> str:
        return os.path.splitdrive(str(self))[0]

    @property
    def root(self) -> str:
        return os.path.sep


class LocalWorkdir(LocalPath):
    """Working directory manipulator"""

    __slots__ = ()
    __hash__ = None  # type: ignore[assignment]

    def __new__(cls) -> Self:
        return super().__new__(cls, os.getcwd())

    def chdir(self, newdir: LocalPath | str) -> LocalWorkdir:
        """Changes the current working directory to the given one

        :param newdir: The destination director (a string or a ``LocalPath``)
        """
        if isinstance(newdir, RemotePath):
            raise TypeError(f"newdir cannot be {newdir!r}")
        logger.debug("Chdir to %s", newdir)
        os.chdir(str(newdir))
        return self.__class__()

    def getpath(self) -> LocalPath:
        """Returns the current working directory as a ``LocalPath`` object"""
        return LocalPath(self._path)

    @contextmanager
    def __call__(self, newdir: LocalPath | str) -> Generator[LocalWorkdir, None, None]:
        """A context manager used to ``chdir`` into a directory and then ``chdir`` back to
        the previous location; much like ``pushd``/``popd``.

        :param newdir: The destination directory (a string or a ``LocalPath``)
        """
        prev = self._path
        newdir = self.chdir(newdir)
        try:
            yield newdir
        finally:
            self.chdir(prev)


__all__ = [
    "LocalPath",
    "LocalWorkdir",
]


def __dir__() -> list[str]:
    return list(__all__)
