"""Directory stack and associated utilities for the xonsh shell.
https://www.gnu.org/software/bash/manual/html_node/Directory-Stack-Builtins.html
"""

import contextlib
import glob
import os
import subprocess
import sys
import typing as tp

from xonsh.built_ins import XSH
from xonsh.cli_utils import Annotated, Arg, ArgParserAlias
from xonsh.events import events
from xonsh.platform import ON_WINDOWS

DIRSTACK: list[str] = []
"""A list containing the currently remembered directories."""
_unc_tempDrives: dict[str, str] = {}
""" drive: sharePath for temp drive letters we create for UNC mapping"""


@contextlib.contextmanager
def _win_reg_key(*paths, **kwargs):
    import winreg

    key = winreg.OpenKey(*paths, **kwargs)
    yield key
    winreg.CloseKey(key)


def _query_win_reg_key(*paths):
    import winreg

    *paths, name = paths

    with contextlib.suppress(OSError):
        with _win_reg_key(*paths) as key:
            wval, wtype = winreg.QueryValueEx(key, name)
            return wval


@tp.no_type_check
def _unc_check_enabled() -> bool:
    r"""Check whether CMD.EXE is enforcing no-UNC-as-working-directory check.

    Check can be disabled by setting {HKCU, HKLM}/SOFTWARE\Microsoft\Command Processor\DisableUNCCheck:REG_DWORD=1

    Returns:
        True if `CMD.EXE` is enforcing the check (default Windows situation)
        False if check is explicitly disabled.
    """
    if not ON_WINDOWS:
        return False

    import winreg

    wval = _query_win_reg_key(
        winreg.HKEY_CURRENT_USER,
        r"software\microsoft\command processor",
        "DisableUNCCheck",
    )

    if wval is None:
        wval = _query_win_reg_key(
            winreg.HKEY_LOCAL_MACHINE,
            r"software\microsoft\command processor",
            "DisableUNCCheck",
        )

    return False if wval else True


def _is_unc_path(some_path) -> bool:
    """True if path starts with 2 backward (or forward, due to python path hacking) slashes."""
    return (
        len(some_path) > 1
        and some_path[0] == some_path[1]
        and some_path[0] in (os.sep, os.altsep)
    )


def _unc_map_temp_drive(unc_path) -> str:
    r"""Map a new temporary drive letter for each distinct share,
    unless `CMD.EXE` is not insisting on non-UNC working directory.

    Emulating behavior of `CMD.EXE` `pushd`, create a new mapped drive (starting from Z: towards A:, skipping existing
     drive letters) for each new UNC path user selects.

    Args:
        unc_path: the path specified by user.  Assumed to be a UNC path of form \\<server>\share...

    Returns:
        a replacement for `unc_path` to be used as the actual new working directory.
        Note that the drive letter may be a the same as one already mapped if the server and share portion of `unc_path`
         is the same as one still active on the stack.
    """
    global _unc_tempDrives
    assert unc_path[1] in (os.sep, os.altsep), "unc_path is UNC form of path"

    if not _unc_check_enabled():
        return unc_path
    unc_share, rem_path = os.path.splitdrive(unc_path)
    unc_share = unc_share.casefold()
    for d in _unc_tempDrives:
        if _unc_tempDrives[d] == unc_share:
            return os.path.join(d, rem_path)

    for dord in range(ord("z"), ord("a"), -1):
        d = chr(dord) + ":"
        if not os.path.isdir(d):  # find unused drive letter starting from z:
            subprocess.check_output(["NET", "USE", d, unc_share], text=True)
            _unc_tempDrives[d] = unc_share
            return os.path.join(d, rem_path)
    raise RuntimeError(f"Failed to find a drive for UNC Path({unc_path})")


def _unc_unmap_temp_drive(left_drive, cwd):
    """Unmap a temporary drive letter if it is no longer needed.
    Called after popping `DIRSTACK` and changing to new working directory, so we need stack *and*
    new current working directory to be sure drive letter no longer needed.

    Args:
        left_drive: driveletter (and colon) of working directory we just left
        cwd: full path of new current working directory
    """

    global _unc_tempDrives

    if left_drive not in _unc_tempDrives:  # if not one we've mapped, don't unmap it
        return

    for p in DIRSTACK + [cwd]:  # if still in use , don't unmap it.
        if p.casefold().startswith(left_drive):
            return

    subprocess.check_output(["NET", "USE", left_drive, "/delete"], text=True)
    _unc_tempDrives.pop(left_drive)


events.doc(
    "on_chdir",
    """
on_chdir(olddir: str, newdir: str) -> None

Fires when the current directory is changed for any reason.

.. code-block:: python

    @events.on_chdir
    def _source_env_xsh(olddir, newdir):
        '''Source `env.xsh` file if it's exist in the new directory.'''
        env_file = @.imp.pathlib.Path(newdir) / 'env.xsh'
        if env_file.exists():
            print(f'Source {env_file}')
            source @(env_file)
""",
)


def _get_cwd():
    try:
        return os.getcwd()
    except OSError:
        return None


def _change_working_directory(newdir, follow_symlinks=False):
    env = XSH.env
    old = env["PWD"]
    new = os.path.join(old, newdir)

    if follow_symlinks:
        new = os.path.realpath(new)
    absnew = os.path.abspath(new)

    try:
        os.chdir(absnew)
    except OSError as e:
        print(f"cd: {e}", file=sys.stderr)
        return
    else:
        if old is not None:
            env["OLDPWD"] = old
        if new is not None:
            env["PWD"] = absnew

    # Fire event if the path actually changed
    if old != env["PWD"]:
        events.on_chdir.fire(olddir=old, newdir=env["PWD"])


def _try_cdpath(apath):
    # NOTE: this CDPATH implementation differs from the bash one.
    # In bash if a CDPATH is set, an unqualified local folder
    # is considered after all CDPATHs, example:
    # CDPATH=$HOME/src (with src/xonsh/ inside)
    # $ cd xonsh -> src/xonsh (with xonsh/xonsh)
    # a second $ cd xonsh has no effects, to move in the nested xonsh
    # in bash a full $ cd ./xonsh is needed.
    # In xonsh a relative folder is always preferred.
    env = XSH.env
    cdpaths = env.get("CDPATH")
    for cdp in cdpaths:
        globber = XSH.expand_path(os.path.join(cdp, apath))
        for cdpath_prefixed_path in glob.iglob(globber):
            return cdpath_prefixed_path
    return apath


def cd(args, stdin=None):
    """Changes the directory.

    If no directory is specified (i.e. if `args` is None) then this
    changes to the current user's home directory.
    """
    env = XSH.env
    oldpwd = env.get("OLDPWD", None)
    cwd = env["PWD"]

    follow_symlinks = False
    if len(args) > 0 and args[0] == "-P":
        follow_symlinks = True
        del args[0]

    if len(args) == 0:
        d = env.get("HOME", os.path.expanduser("~"))
    elif len(args) == 1:
        d = args[0]
        if not os.path.isdir(d):
            if d == "-":
                if oldpwd is not None:
                    d = oldpwd
                else:
                    return "", "cd: no previous directory stored\n", 1
            elif d.startswith("-"):
                try:
                    num = int(d[1:])
                except ValueError:
                    return "", f"cd: Invalid destination: {d}\n", 1
                if num == 0:
                    return None, None, 0
                elif num < 0:
                    return "", f"cd: Invalid destination: {d}\n", 1
                elif num > len(DIRSTACK):
                    e = "cd: Too few elements in dirstack ({0} elements)\n"
                    return "", e.format(len(DIRSTACK)), 1
                else:
                    d = DIRSTACK[num - 1]
            else:
                d = _try_cdpath(d)
    else:
        return (
            "",
            (
                f"cd takes 0 or 1 arguments, not {len(args)}. An additional `-P` "
                "flag can be passed in first position to follow symlinks."
                "\n"
            ),
            1,
        )
    if not os.path.exists(d):
        return "", f"cd: no such file or directory: {d}\n", 1
    if not os.path.isdir(d):
        return "", f"cd: {d} is not a directory\n", 1
    if not os.access(d, os.X_OK):
        return "", f"cd: permission denied: {d}\n", 1
    if (
        ON_WINDOWS
        and _is_unc_path(d)
        and _unc_check_enabled()
        and (not env.get("AUTO_PUSHD"))
    ):
        return (
            "",
            "cd: can't cd to UNC path on Windows, unless $AUTO_PUSHD set or reg entry "
            + r"HKCU\SOFTWARE\MICROSOFT\Command Processor\DisableUNCCheck:DWORD = 1"
            + "\n",
            1,
        )

    # now, push the directory onto the dirstack if AUTO_PUSHD is set
    if cwd is not None and env.get("AUTO_PUSHD"):
        pushd(["-n", "-q", cwd])
        if ON_WINDOWS and _is_unc_path(d):
            d = _unc_map_temp_drive(d)
    _change_working_directory(d, follow_symlinks)
    return None, None, 0


def pushd_fn(
    dir_or_n: Annotated[str | None, Arg(metavar="+N|-N|dir", nargs="?")] = None,
    cd=True,
    quiet=False,
):
    r"""Adds a directory to the top of the directory stack, or rotates the stack,
    making the new top of the stack the current working directory.

    On Windows, if the path is a UNC path (begins with `\\<server>\<share>`) and if the `DisableUNCCheck` registry
    value is not enabled, creates a temporary mapped drive letter and sets the working directory there, emulating
    behavior of `PUSHD` in `CMD.EXE`

    Parameters
    ----------
    dir_or_n
        * dir :
            Makes dir be the top of the stack,
            making it the new current directory as if it had been supplied as an argument to the cd builtin.
        * +N :
            Brings the Nth directory (counting from the left of the list printed by dirs, starting with zero)
            to the top of the list by rotating the stack.
        * -N :
            Brings the Nth directory (counting from the right of the list printed by dirs, starting with zero)
            to the top of the list by rotating the stack.
    cd : -n, --cd
        Suppresses the normal change of directory when adding directories to the stack,
        so that only the stack is manipulated.
    quiet : -q, --quiet
        Do not call dirs, regardless of $PUSHD_SILENT
    """
    global DIRSTACK

    env = XSH.env

    pwd = env["PWD"]

    if env.get("PUSHD_MINUS", False):
        BACKWARD = "-"
        FORWARD = "+"
    else:
        BACKWARD = "+"
        FORWARD = "-"

    if dir_or_n is None:
        try:
            new_pwd: str | None = DIRSTACK.pop(0)
        except IndexError:
            e = "pushd: Directory stack is empty\n"
            return None, e, 1
    elif os.path.isdir(dir_or_n):
        new_pwd = dir_or_n
    else:
        try:
            num = int(dir_or_n[1:])
        except ValueError:
            e = "Invalid argument to pushd: {0}\n"
            return None, e.format(dir_or_n), 1

        if num < 0:
            e = "Invalid argument to pushd: {0}\n"
            return None, e.format(dir_or_n), 1

        if num > len(DIRSTACK):
            e = "Too few elements in dirstack ({0} elements)\n"
            return None, e.format(len(DIRSTACK)), 1
        elif dir_or_n.startswith(FORWARD):
            if num == len(DIRSTACK):
                new_pwd = None
            else:
                new_pwd = DIRSTACK.pop(len(DIRSTACK) - 1 - num)
        elif dir_or_n.startswith(BACKWARD):
            if num == 0:
                new_pwd = None
            else:
                new_pwd = DIRSTACK.pop(num - 1)
        else:
            e = "Invalid argument to pushd: {0}\n"
            return None, e.format(dir_or_n), 1
    if new_pwd is not None:
        if ON_WINDOWS and _is_unc_path(new_pwd):
            new_pwd = _unc_map_temp_drive(new_pwd)
        if cd:
            DIRSTACK.insert(0, os.path.expanduser(pwd))
            _change_working_directory(new_pwd)
        else:
            DIRSTACK.insert(0, os.path.expanduser(new_pwd))

    maxsize = env.get("DIRSTACK_SIZE")
    if len(DIRSTACK) > maxsize:
        DIRSTACK = DIRSTACK[:maxsize]

    if not quiet and not env.get("PUSHD_SILENT"):
        return dirs([], None)

    return None, None, 0


pushd = ArgParserAlias(func=pushd_fn, has_args=True, prog="pushd")


def popd_fn(
    nth: Annotated[str | None, Arg(metavar="+N|-N", nargs="?")] = None,
    cd=True,
    quiet=False,
):
    """When no arguments are given, popd removes the top directory from the stack
    and performs a cd to the new top directory.
    The elements are numbered from 0 starting at the first directory listed with ``dirs``;
    that is, popd is equivalent to popd +0.

    Parameters
    ----------
    cd : -n, --cd
        Suppresses the normal change of directory when removing directories from the stack,
        so that only the stack is manipulated.
    nth
        Removes the Nth directory (counting from the left/right of the list printed by dirs w.r.t. -/+ prefix),
        starting with zero.
    quiet : -q, --quiet
        Do not call dirs, regardless of $PUSHD_SILENT
    """
    global DIRSTACK

    env = XSH.env

    if env.get("PUSHD_MINUS"):
        BACKWARD = "-"
        FORWARD = "+"
    else:
        BACKWARD = "+"
        FORWARD = "-"

    new_pwd: str | None = None
    if nth is None:
        try:
            new_pwd = DIRSTACK.pop(0)
        except IndexError:
            e = "popd: Directory stack is empty\n"
            return None, e, 1
    else:
        try:
            num = int(nth[1:])
        except ValueError:
            e = "Invalid argument to popd: {0}\n"
            return None, e.format(nth), 1

        if num < 0:
            e = "Invalid argument to popd: {0}\n"
            return None, e.format(nth), 1

        if not DIRSTACK:
            # +0/-0 otherwise reach DIRSTACK.pop(0) below and raise IndexError.
            e = "Too few elements in dirstack ({0} elements)\n"
            return None, e.format(len(DIRSTACK)), 1

        if num > len(DIRSTACK):
            e = "Too few elements in dirstack ({0} elements)\n"
            return None, e.format(len(DIRSTACK)), 1
        elif nth.startswith(FORWARD):
            if num == len(DIRSTACK):
                new_pwd = DIRSTACK.pop(0)
            else:
                DIRSTACK.pop(len(DIRSTACK) - 1 - num)
        elif nth.startswith(BACKWARD):
            if num == 0:
                new_pwd = DIRSTACK.pop(0)
            else:
                DIRSTACK.pop(num - 1)
        else:
            e = "Invalid argument to popd: {0}\n"
            return None, e.format(nth), 1

    if new_pwd is not None:
        if cd:
            env = XSH.env
            pwd = env["PWD"]

            _change_working_directory(new_pwd)

            if ON_WINDOWS:
                drive, rem_path = os.path.splitdrive(pwd)
                _unc_unmap_temp_drive(drive.casefold(), new_pwd)

    if not quiet and not env.get("PUSHD_SILENT"):
        return dirs([], None)

    return None, None, 0


popd = ArgParserAlias(func=popd_fn, has_args=True, prog="popd")


def dirs_fn(
    nth: Annotated[str | None, Arg(metavar="N", nargs="?")] = None,
    clear=False,
    print_long=False,
    verbose=False,
    long=False,
):
    """Manage the list of currently remembered directories.

    Parameters
    ----------
    nth
        Displays the Nth directory (counting from the left/right according to +/x prefix respectively),
        starting with zero
    clear : -c
        Clears the directory stack by deleting all of the entries.
    print_long : -p
        Print the directory stack with one entry per line.
    verbose : -v
        Print the directory stack with one entry per line,
        prefixing each entry with its index in the stack.
    long : -l
        Produces a longer listing; the default listing format
        uses a tilde to denote the home directory.
    """
    global DIRSTACK

    env = XSH.env
    dirstack = [os.path.expanduser(env["PWD"])] + DIRSTACK

    if env.get("PUSHD_MINUS"):
        BACKWARD = "-"
        FORWARD = "+"
    else:
        BACKWARD = "+"
        FORWARD = "-"

    if clear:
        DIRSTACK = []
        return None, None, 0

    if long:
        o = dirstack
    else:
        d = os.path.expanduser("~")
        o = [i.replace(d, "~") for i in dirstack]

    if verbose:
        out = ""
        pad = len(str(len(o) - 1))
        for ix, e in enumerate(o):
            blanks = " " * (pad - len(str(ix)))
            out += f"\n{blanks}{ix} {e}"
        out = out[1:]
    elif print_long:
        out = "\n".join(o)
    else:
        out = " ".join(o)

    if nth is not None:
        try:
            num = int(nth[1:])
        except ValueError:
            e = "Invalid argument to dirs: {0}\n"
            return None, e.format(nth), 1

        if num < 0:
            e = "Invalid argument to dirs: {0}\n"
            return None, e.format(nth), 1

        if num >= len(o):
            e = "Too few elements in dirstack ({0} elements)\n"
            return None, e.format(len(o)), 1

        if nth.startswith(BACKWARD):
            idx = num
        elif nth.startswith(FORWARD):
            idx = len(o) - 1 - num
        else:
            e = "Invalid argument to dirs: {0}\n"
            return None, e.format(nth), 1

        out = o[idx]

    return out + "\n", None, 0


dirs = ArgParserAlias(prog="dirs", func=dirs_fn, has_args=True)


@contextlib.contextmanager
def with_pushd(d):
    """Use pushd as a context manager"""
    _out, err, rtn = pushd_fn(d)
    if rtn:
        raise RuntimeError(err or f"pushd failed for {d!r}")
    try:
        yield
    finally:
        popd_fn()
