import functools
import json
import re
import shutil
import subprocess
import textwrap
from pathlib import Path

from xonsh.built_ins import XSH
from xonsh.completers.tools import RichCompletion, contextual_command_completer
from xonsh.parsers.completion_context import CommandContext


@functools.cache
def get_man_completions_path() -> Path:
    env = XSH.env or {}
    datadir = Path(env["XONSH_DATA_DIR"]) / "generated_completions" / "man"
    if datadir.exists() and (not datadir.is_dir()):
        shutil.move(datadir, datadir.with_suffix(".bkp"))
    if not datadir.exists():
        datadir.mkdir(exist_ok=True, parents=True)
    return datadir


def _get_man_page(cmd: str):
    """without control characters"""
    env = XSH.env.detype()
    # Use context manager to ensure man's Popen is waited on and its
    # stdout fd is closed. Without this, the man process becomes a
    # zombie (never reaped) and the pipe fd leaks.
    with subprocess.Popen(
        ["man", cmd], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env
    ) as manpage:
        # This is a trick to get rid of reverse line feeds
        result = subprocess.check_output(["col", "-b"], stdin=manpage.stdout, env=env)
        if manpage.stdout:
            manpage.stdout.close()
        manpage.wait()
        return result


@functools.cache
def _man_option_string_regex():
    return re.compile(
        r"(?:(,\s?)|^|(\sor\s))(?P<option>-[\w]|--[\w-]+)(?=\[?(\s|,|=|$))"
    )


def generate_options_of(cmd: str):
    out = _get_man_page(cmd)
    if not out:
        return

    def get_headers(text: str):
        """split as header-body based on indent"""
        if not text:
            return
        header = ""
        body = []
        for line in textwrap.dedent(text.expandtabs(8)).splitlines():
            if not line.strip():
                continue
            if line.startswith((" ", "\t")):
                body.append(line)
            else:
                if header or body:
                    yield header, body

                # found new section
                header = line.strip()
                body = []
        if header or body:
            yield header, body

    def split_options_string(text: str):
        text = text.strip()
        regex = _man_option_string_regex()

        options = []
        for match in regex.finditer(text):
            option = match.groupdict().pop("option", None)
            if option:
                options.append(option)
            text = text[match.end() :]
        return options, text.strip()

    def get_option_section():
        option_sect = dict(get_headers(out.decode()))
        small_names = {k.lower(): k for k in option_sect}
        for head in (
            "options",
            "command options",
            "command line options",
            "description",
        ):  # prefer sections in this order
            if head in small_names:
                title = small_names[head]
                return "\n".join(option_sect[title])

    def get_options(text):
        """finally get the options"""
        # return old section if
        for opt, lines in get_headers(text):
            # todo: some have [+-] or such vague notations
            if opt.startswith("-"):
                # sometime a single line will have both desc and options
                option_strings, rest = split_options_string(opt)
                descs = []
                if rest:
                    descs.append(rest)
                if lines:
                    descs.append(textwrap.dedent("\n".join(lines)))
                if option_strings:
                    yield ". ".join(descs), tuple(option_strings)
            elif lines:
                # sometimes the options are nested inside subheaders
                yield from get_options("\n".join(lines))

    yield from get_options(get_option_section())


def _man_page_path(cmd: str) -> "Path | None":
    """Return the file path of cmd's man page, or None."""
    try:
        out = subprocess.check_output(
            ["man", "-w", cmd], stderr=subprocess.DEVNULL, text=True
        )
        p = Path(out.strip())
        return p if p.exists() else None
    except (subprocess.CalledProcessError, OSError):
        return None


@functools.lru_cache(maxsize=10)
def _parse_man_page_options(cmd: str) -> "dict[str, tuple[str, ...]]":
    cache = get_man_completions_path() / Path(cmd).with_suffix(".json").name
    if cache.exists():
        # Invalidate if the man page is newer than the cached JSON.
        man = _man_page_path(cmd)
        if man is None or man.stat().st_mtime <= cache.stat().st_mtime:
            return json.loads(cache.read_text())
    options = dict(generate_options_of(cmd))
    cache.write_text(json.dumps(options))
    return options


@contextual_command_completer
def complete_from_man(context: CommandContext):
    """
    Completes an option name, based on the contents of the associated man
    page.
    """

    if context.arg_index == 0 or not context.prefix.startswith("-"):
        return
    cmd = context.args[0].value

    # Tools like cargo, docker use per-subcommand man pages
    # (e.g. cargo-build). Try the hyphenated form first.
    if context.arg_index >= 2:
        subcmd_man = f"{cmd}-{context.args[1].value}"
        if _man_page_path(subcmd_man) is not None:
            cmd = subcmd_man

    def completions():
        for desc, opts in _parse_man_page_options(cmd).items():
            yield RichCompletion(
                value=opts[-1],
                display=", ".join(opts),
                description=desc,
                provider=f"man:{cmd}",
            )

    return completions(), False
