import ast
import glob
import os
import re

import xonsh.platform as xp
import xonsh.tools as xt
from xonsh.built_ins import XSH
from xonsh.completers.tools import RichCompletion, contextual_completer
from xonsh.lib.completion_quoting import name_needs_quotes
from xonsh.parsers.completion_context import CommandContext


def cd_in_command(line):
    """Returns True if "cd" is a token in the line, False otherwise."""
    lexer = XSH.execer.parser.lexer
    lexer.reset()
    lexer.input(line)
    have_cd = False
    for tok in lexer:
        if tok.type == "NAME" and tok.value == "cd":
            have_cd = True
            break
    return have_cd


def _get_normalized_pstring_quote(s):
    for pre, norm_pre in (("p", "p"), ("pr", "pr"), ("rp", "pr"), ("fp", "pf")):
        for q in ('"', "'"):
            if s.startswith(f"{pre}{q}"):
                return norm_pre, q
    return (None, None)


def _path_from_partial_string(inp, pos=None):
    if pos is None:
        pos = len(inp)
    partial = inp[:pos]
    startix, endix, quote = xt.check_for_partial_string(partial)
    _post = ""
    if startix is None:
        return None
    elif endix is None:
        string = partial[startix:]
    else:
        if endix != pos:
            _test = partial[endix:pos]
            if not any(i == " " for i in _test):
                _post = _test
            else:
                return None
        string = partial[startix:endix]

    # If 'pr'/'rp', treat as raw string, otherwise strip leading 'p'
    pstring_pre = _get_normalized_pstring_quote(quote)[0]
    if pstring_pre == "pr":
        string = f"r{string[2:]}"
    elif pstring_pre == "p":
        string = string[1:]

    end = xt.RE_STRING_START.sub("", quote)
    _string = string
    if not _string.endswith(end):
        _string = _string + end
    try:
        val = ast.literal_eval(_string)
    except (SyntaxError, ValueError):
        # Raw strings can't end with an odd number of backslashes
        # (e.g. r"C:\App\" is a SyntaxError). Extract the path directly.
        raw_prefix = xt.RE_STRING_START.match(string)
        if raw_prefix and "r" in raw_prefix.group().lower():
            val = string[raw_prefix.end() + len(end) :]
        else:
            return None
    if isinstance(val, bytes):
        env = XSH.env
        val = val.decode(
            encoding=env.get("XONSH_ENCODING"), errors=env.get("XONSH_ENCODING_ERRORS")
        )
    return string + _post, val + _post, quote, end


def _normpath(p):
    """
    Wraps os.normpath() to avoid removing './' at the beginning
    and '/' at the end. On windows it does the same with backslashes
    """
    initial_dotslash = p.startswith(os.curdir + os.sep)
    initial_dotslash |= xp.ON_WINDOWS and p.startswith(os.curdir + os.altsep)
    p = p.rstrip(" ")
    trailing_slash = p.endswith(os.sep)
    trailing_slash |= xp.ON_WINDOWS and p.endswith(os.altsep)
    p = os.path.normpath(p)
    if initial_dotslash and p != ".":
        p = os.path.join(os.curdir, p)
    if trailing_slash:
        p = os.path.join(p, "")
    if xp.ON_WINDOWS and XSH.env.get("FORCE_POSIX_PATHS"):
        p = p.replace(os.sep, os.altsep)
    return p


def _startswithlow(x, start, startlow=None):
    if startlow is None:
        startlow = start.lower()
    return x.startswith(start) or x.lower().startswith(startlow)


def _startswithnorm(x, start, startlow=None):
    return x.startswith(start)


def _dots(prefix):
    complete_dots = XSH.env.get("COMPLETE_DOTS", "matching").lower()
    if complete_dots == "never":
        return ()
    slash = xt.get_sep()
    if slash == "\\":
        slash = ""
    prefixes = {"."}
    if complete_dots == "always":
        prefixes.add("")
    if prefix in prefixes:
        return ("." + slash, ".." + slash)
    elif prefix == "..":
        return (".." + slash,)
    else:
        return ()


def _add_cdpaths(paths, prefix):
    """Completes current prefix using CDPATH"""
    env = XSH.env
    glob_sorted = env.get("GLOB_SORTED")
    for cdp in env.get("CDPATH"):
        test_glob = os.path.join(cdp, prefix) + "*"
        for s in xt.iglobpath(
            test_glob, ignore_case=(not xp.ON_WINDOWS), sort_result=glob_sorted
        ):
            if os.path.isdir(s):
                paths.add(os.path.relpath(s, cdp))


def _quote_to_use(x):
    single = "'"
    double = '"'
    if single in x and double not in x:
        return double
    else:
        return single


def _is_directory_in_cdpath(path):
    env = XSH.env
    for cdp in env.get("CDPATH"):
        if os.path.isdir(os.path.join(cdp, path)):
            return True
    return False


_CONTROL_CHAR_ESCAPE = str.maketrans(
    {
        "\n": "\\n",
        "\t": "\\t",
        "\r": "\\r",
        "\f": "\\f",
        "\v": "\\v",
    }
)


def _has_control_chars(s):
    return any(chr(c) in s for c in _CONTROL_CHAR_ESCAPE)


def _raw_quote(s):
    """Wrap *s* in r'...' with a trailing-backslash fix.

    Raw strings can't end with an odd number of backslashes before the
    closing quote (``r'path\\'`` is valid, ``r'path\\'`` is not).  Double
    a lone trailing backslash so the result is always valid syntax.
    """
    if s.endswith("\\") and not s.endswith("\\\\"):
        s += "\\"
    return "r'" + s + "'"


def _quote_paths(paths, start, end, append_end=True, cdpath=False):
    expand_path = XSH.expand_path
    out = set()
    space = " "
    backslash = "\\"
    double_backslash = "\\\\"
    slash = xt.get_sep()
    orig_start = start
    orig_end = end
    # Decide per-path whether quoting is needed: a plain ``file`` should
    # appear unquoted even when a sibling ``fi$le`` requires ``r'…'``.
    # Prompt-toolkit's completion menu shows both forms side by side, so
    # the historical "quote all or none" rule (needed for readline's
    # longest-common-prefix) is no longer warranted.
    need_quotes = False

    for s in paths:
        start = orig_start
        end = orig_end
        path_needs_quotes = name_needs_quotes(s, sep=slash)
        if path_needs_quotes:
            need_quotes = True
        if start == "" and path_needs_quotes:
            start = end = _quote_to_use(s)
        # For a bare ``~`` or ``$VAR`` use the literal path for isdir —
        # expand_path("$HOME") and expand_path("~") resolve to the home
        # directory (always a dir), masking a local file with that exact
        # name. Compound paths like ``~/git`` or ``$HOME/bin`` are
        # unambiguous (a separator means it's a subpath, not a literal
        # filename), so fall through to ``expand_path`` — otherwise
        # ``os.path.isdir("~/git")`` is False and the trailing-sep
        # detection for directories breaks.
        has_sep = os.sep in s or (bool(os.altsep) and os.altsep in s)
        ambiguous_literal = not has_sep and ("$" in s or s.startswith("~"))
        check_path = s if ambiguous_literal else expand_path(s)
        is_dir = os.path.isdir(check_path) or (
            cdpath and _is_directory_in_cdpath(check_path)
        )
        if is_dir:
            _tail = slash
        elif end == "":
            _tail = space
        else:
            _tail = ""
        # Filenames with control characters (newline, tab, etc.) must use
        # regular (non-raw) strings so the escape sequences are interpreted.
        has_ctrl = _has_control_chars(s)
        needs_raw = (backslash in s or "$" in s) and not has_ctrl
        if start != "" and "r" not in start.lower() and needs_raw:
            start = f"r{start}"
        s = s + _tail
        # Raw strings can't end with \ before closing quote (e.g. r"path\" is
        # a SyntaxError). Double the trailing backslash so it's valid (r"path\\").
        if "r" in start.lower() and end != "" and s.endswith(backslash):
            s = s + backslash
        if end != "":
            if "r" not in start.lower():
                s = s.replace(backslash, double_backslash)
        if end in s:
            s = s.replace(end, "".join(f"\\{i}" for i in end))
        # Translate control chars AFTER all backslash escaping so the
        # introduced backslashes are not doubled.
        if has_ctrl:
            s = s.translate(_CONTROL_CHAR_ESCAPE)
        s = start + s + end if append_end else start + s
        # For a non-directory completion inside quotes, append the trailing
        # separator space AFTER the closing quote so `ls 'f<Tab>` advances
        # to the next arg just like `ls f<Tab>` does. Skip when
        # ``append_end=False``: the closing quote is already in the line
        # after the cursor, and a space here would fall inside it.
        if not is_dir and end != "" and append_end:
            s = s + space
        out.add(s)
    return out, need_quotes


def _joinpath(path):
    # convert our tuple representation back into a string representing a path
    if path is None:
        return ""
    elif len(path) == 0:
        return ""
    elif path == ("",):
        return xt.get_sep()
    elif path[0] == "":
        return xt.get_sep() + _normpath(os.path.join(*path))
    else:
        return _normpath(os.path.join(*path))


def _splitpath(path):
    # convert a path into an intermediate tuple representation
    # if this tuple starts with '', it means that the path was an absolute path
    path = _normpath(path)
    if path.startswith(xt.get_sep()):
        pre = ("",)
    else:
        pre = ()
    return pre + _splitpath_helper(path, ())


def _splitpath_helper(path, sofar=()):
    folder, path = os.path.split(path)
    if path:
        sofar = sofar + (path,)
    if not folder or folder == xt.get_sep():
        return sofar[::-1]
    elif xp.ON_WINDOWS and not path:
        return os.path.splitdrive(folder)[:1] + sofar[::-1]
    elif xp.ON_WINDOWS and os.path.splitdrive(path)[0]:
        return sofar[::-1]
    return _splitpath_helper(folder, sofar)


def subsequence_match(ref, typed, csc):
    """
    Detects whether typed is a subsequence of ref.

    Returns ``True`` if the characters in ``typed`` appear (in order) in
    ``ref``, regardless of exactly where in ``ref`` they occur.  If ``csc`` is
    ``False``, ignore the case of ``ref`` and ``typed``.

    Used in "subsequence" path completion (e.g., ``~/u/ro`` expands to
    ``~/lou/carcohl``)
    """
    if csc:
        return _subsequence_match_iter(ref, typed)
    else:
        return _subsequence_match_iter(ref.lower(), typed.lower())


def _subsequence_match_iter(ref, typed):
    it = iter(ref)
    return all(c in it for c in typed)


def _expand_one(sofar, nextone, csc):
    out = set()
    glob_sorted = XSH.env.get("GLOB_SORTED")
    for i in sofar:
        _glob = os.path.join(_joinpath(i), "*") if i is not None else "*"
        for j in xt.iglobpath(_glob, sort_result=glob_sorted):
            j = os.path.basename(j)
            if subsequence_match(j, nextone, csc):
                out.add((i or ()) + (j,))
    return out


def _complete_path_raw(prefix, line, start, end, ctx, cdpath=True, filtfunc=None):
    # string stuff for automatic quoting
    path_str_start = ""
    path_str_end = ""
    append_end = True
    p = _path_from_partial_string(line, end)
    lprefix = len(prefix)
    if p is not None:
        lprefix = len(p[0])
        # Compensate for 'p' if p-string variant
        pstring_pre = _get_normalized_pstring_quote(p[2])[0]
        if pstring_pre in ("pr", "p"):
            lprefix += 1
        prefix = p[1]
        path_str_start = p[2]
        path_str_end = p[3]
        if len(line) >= end + 1 and line[end] == path_str_end:
            append_end = False
    tilde = "~"
    # Raw strings (r'...') treat ~ literally — skip tilde expansion
    # so that r'~/' completes inside a directory named ~ in cwd.
    is_raw_string = "r" in path_str_start.lower() if path_str_start else False
    paths = set()
    env = XSH.env
    glob_sorted = env.get("GLOB_SORTED")
    prefix = glob.escape(prefix)
    # On Windows, bare ~ is not expanded by windows_expanduser (it requires
    # a trailing separator).  Append os.sep so that iglobpath lists the
    # home directory contents, matching the behavior of ~\ and ~/.
    # Skip when a literal file/dir named ~ exists in cwd (PR #6339).
    if (
        xp.ON_WINDOWS
        and prefix == tilde
        and not is_raw_string
        and not os.path.exists(tilde)
    ):
        prefix = tilde + os.sep
    _prefix_is_dir_listing = prefix.endswith(os.sep) or (
        os.altsep and prefix.endswith(os.altsep)
    )
    if is_raw_string and prefix.startswith(tilde):
        # Use glob.glob directly to avoid iglobpath's expanduser
        for s in (
            sorted(glob.glob(prefix + "*")) if glob_sorted else glob.iglob(prefix + "*")
        ):
            paths.add(s)
    else:
        for s in xt.iglobpath(
            prefix + "*", ignore_case=(not xp.ON_WINDOWS), sort_result=glob_sorted
        ):
            paths.add(s)
    # Substring matches: *prefix* catches files containing the prefix
    # anywhere in their name.  The pipeline's tier-based sort ensures
    # prefix matches rank above substring matches.
    if (
        prefix
        and not _prefix_is_dir_listing
        and env.get("XONSH_COMPLETER_MODE", "substring_tier") == "substring_tier"
    ):
        dirpart = os.path.dirname(prefix)
        namepart = os.path.basename(prefix)
        if namepart:
            pattern = (
                os.path.join(dirpart, "*" + namepart + "*")
                if dirpart
                else "*" + namepart + "*"
            )
            for s in xt.iglobpath(
                pattern, ignore_case=(not xp.ON_WINDOWS), sort_result=glob_sorted
            ):
                paths.add(s)
    if (
        len(paths) == 0
        and env.get("SUBSEQUENCE_PATH_COMPLETION")
        and not _prefix_is_dir_listing
    ):
        # this block implements 'subsequence' matching, similar to fish and zsh.
        # matches are based on subsequences, not substrings.
        # e.g., ~/u/ro completes to ~/lou/carcolh
        # see above functions for details.
        p = _splitpath(os.path.expanduser(prefix))
        p_len = len(p)
        if p_len != 0:
            relative_char = ["", ".", ".."]
            if p[0] in relative_char:
                i = 0
                while i < p_len and p[i] in relative_char:
                    i += 1
                basedir = p[:i]
                p = p[i:]
            else:
                basedir = None
            matches_so_far = {basedir}
            for i in p:
                matches_so_far = _expand_one(matches_so_far, i, False)
            paths |= {_joinpath(i) for i in matches_so_far}
    if (
        len(paths) == 0
        and env.get("FUZZY_PATH_COMPLETION")
        and not _prefix_is_dir_listing
    ):
        threshold = env.get("SUGGEST_THRESHOLD")
        for s in xt.iglobpath(
            os.path.dirname(prefix) + "*",
            ignore_case=(not xp.ON_WINDOWS),
            sort_result=glob_sorted,
        ):
            if xt.levenshtein(prefix, s, threshold) < threshold:
                paths.add(s)
    if cdpath and cd_in_command(line):
        _add_cdpaths(paths, prefix)
    paths = set(filter(filtfunc, paths))
    if tilde in prefix and not xp.ON_WINDOWS:
        home = os.path.expanduser(tilde)
        paths = {s.replace(home, tilde) for s in paths}
    paths, _ = _quote_paths(
        {_normpath(s) for s in paths}, path_str_start, path_str_end, append_end, cdpath
    )
    # When a literal file/directory named ~ exists in cwd, add r'~...'
    # completions so the user can select the local file instead of $HOME.
    # This handles both "cd ~<Tab>" (prefix starts with ~) and "cd <Tab>"
    # (empty prefix, ~ found by glob in cwd).
    if not is_raw_string and os.path.exists(tilde):
        tilde_remove = set()
        for p in list(paths):
            raw = p.rstrip()
            # Skip entries already wrapped as r'...'.
            if raw.startswith(("r'", 'r"')):
                continue
            # Strip optional quotes to inspect the path content.
            inner = raw
            if inner[:1] in ("'", '"'):
                inner = inner[1:]
            if inner[-1:] in ("'", '"'):
                inner = inner[:-1]
            # Unescape and strip trailing separator to compare.
            inner = inner.replace("\\\\", "\\").rstrip("\\/ ")
            if inner == tilde:
                tilde_remove.add(p)
        # Add the correct r'~...' entry for the literal ~ path.
        if not filtfunc or filtfunc(tilde):
            slash = xt.get_sep()
            entry = tilde + slash if os.path.isdir(tilde) else tilde
            paths -= tilde_remove
            paths.add(_raw_quote(entry))
    paths.update(filter(filtfunc, _dots(prefix)))
    return paths, lprefix


@contextual_completer
def complete_path(context):
    """Completes path names."""
    if context.command:
        return contextual_complete_path(context.command)
    elif context.python:
        line = context.python.prefix
        # simple prefix _complete_path_raw will handle gracefully:
        prefix = line.rsplit(" ", 1)[-1]
        return _complete_path_raw(prefix, line, len(line) - len(prefix), len(line), {})
    return set(), 0


# Matches an ``option=value`` argument so path completion can target only the
# value (e.g. ``--path=/home/`` → ``/home/``).  The option name must not
# contain a path separator, so a real path that contains ``=`` (``/home/a=b/``
# or a directory named ``qwe=asd``) is never split.
_OPTION_ASSIGN_RE = re.compile(r"^[^/\\=\s]*=(.*)$")


def contextual_complete_path(command: CommandContext, cdpath=True, filtfunc=None):
    def _complete(prefix):
        # ``_complete_path_raw`` may add opening quotes.
        # When the cursor is inside a closed string (before the closing quote),
        # append the closing quote to the line so ``_complete_path_raw`` can
        # detect it and set ``append_end = False``.  The completion will NOT
        # include a closing quote, and lprefix will NOT cover the original
        # closing quote — so the existing quote stays in place.
        if command.closing_quote and not command.is_after_closing_quote:
            line = prefix + command.closing_quote
        else:
            line = prefix
        return _complete_path_raw(
            prefix,
            line,
            0,
            len(prefix),
            ctx={},
            cdpath=cdpath,
            filtfunc=filtfunc,
        )

    prefix = command.raw_prefix
    completions, lprefix = _complete(prefix)

    # Complete the value of an ``option=value`` argument (e.g.
    # ``--path=/home/`` or ``if=/dev/``): only the part after the first ``=``
    # is treated as a path.  The whole prefix is tried first, so a real path
    # that itself contains ``=`` (a file/dir literally named ``qwe=asd``)
    # still completes as a whole — we only fall back to the value when the
    # literal prefix matched nothing.  ``lprefix`` then covers only the value,
    # so the ``option=`` part is preserved in the line.
    if not completions and not command.opening_quote:
        eq_match = _OPTION_ASSIGN_RE.match(prefix)
        if eq_match:
            prefix = eq_match.group(1)
            completions, lprefix = _complete(prefix)

    # Set an explicit display on completions whose text no longer
    # starts with the typed prefix (e.g. expanded tilde on Windows:
    # prefix "~" → completion "C:/Users/...").  Without this, the ptk
    # completer strips ``len(prefix)`` chars from the front of the
    # display, eating the drive letter.
    rich_completions = set()
    for comp in completions:
        # Strip quote prefix (r', r", ', ") to get the path content.
        inner = comp
        if inner[:2] in ("r'", 'r"', "R'", 'R"'):
            inner = inner[2:]
        elif inner[:1] in ("'", '"'):
            inner = inner[1:]
        needs_display = not inner.startswith(prefix)
        display = comp if needs_display else None
        rich_completions.add(
            RichCompletion(comp, display=display, append_closing_quote=False)
        )

    return rich_completions, lprefix


def complete_dir(command: CommandContext):
    return contextual_complete_path(command, filtfunc=os.path.isdir)
