import functools
import itertools
import os
import re
import subprocess
from typing import (
    Union,
    Sequence,
    Optional,
    Iterable,
    List,
    Iterator,
    Tuple,
    FrozenSet,
)

from debputy.dh.dh_assistant import (
    resolve_active_and_inactive_dh_commands,
    DhListCommands,
)
from debputy.linting.lint_util import LintState
from debputy.lsp.config.config_options import DCO_SPELLCHECK_COMMENTS
from debputy.lsp.debputy_ls import DebputyLanguageServer
from debputy.lsp.lsp_features import (
    lint_diagnostics,
    lsp_standard_handler,
    lsp_completer,
    SecondaryLanguage,
    LanguageDispatchRule,
)
from debputy.lsp.quickfixes import propose_correct_text_quick_fix
from debputy.lsp.spellchecking import spellcheck_line
from debputy.lsprotocol.types import (
    CompletionItem,
    CompletionList,
    CompletionParams,
    TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL,
    TEXT_DOCUMENT_CODE_ACTION,
)
from debputy.util import detect_possible_typo

try:
    from debputy.lsp.vendoring._deb822_repro.locatable import (
        Position as TEPosition,
        Range as TERange,
    )

    from pygls.server import LanguageServer
    from pygls.workspace import TextDocument
except ImportError:
    pass


_CONTAINS_TAB_OR_COLON = re.compile(r"[\t:]")
_WORDS_RE = re.compile("([a-zA-Z0-9_-]+)")
_MAKE_ERROR_RE = re.compile(r"^[^:]+:(\d+):\s*(\S.+)")
_STANDARD_MAKEFILES = [
    "/usr/share/dpkg/architecture.mk",
    "/usr/share/dpkg/buildapi.mk",
    "/usr/share/dpkg/buildflags.mk",
    "/usr/share/dpkg/buildtools.mk",
    "/usr/share/dpkg/default.mk",
    "/usr/share/dpkg/vendor.mk",
    "/usr/share/dpkg/pkg-info.mk",
]

_KNOWN_TARGETS = {
    "binary",
    "binary-arch",
    "binary-indep",
    "build",
    "build-arch",
    "build-indep",
    "clean",
}

_COMMAND_WORDS = frozenset(
    {
        "export",
        "ifeq",
        "ifneq",
        "ifdef",
        "ifndef",
        "endif",
        "else",
    }
)
_DISPATCH_RULE = LanguageDispatchRule.new_rule(
    "debian/rules",
    None,
    "debian/rules",
    [
        # emacs's name (there is no debian-rules mode)
        SecondaryLanguage("makefile-gmake", secondary_lookup="path-name"),
        # vim's name (there is no debrules and it does not use the official makefile language name)
        SecondaryLanguage("make", secondary_lookup="path-name"),
        # LSP's official language ID for Makefile
        SecondaryLanguage("makefile", secondary_lookup="path-name"),
    ],
)


def _as_hook_targets(command_name: str) -> Iterable[str]:
    for prefix, suffix in itertools.product(
        ["override_", "execute_before_", "execute_after_"],
        ["", "-arch", "-indep"],
    ):
        yield f"{prefix}{command_name}{suffix}"


lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION)
lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL)


@functools.lru_cache
def _is_project_trusted(source_root: str) -> bool:
    return os.environ.get("DEBPUTY_TRUST_PROJECT", "0") == "1"


def _run_make_dryrun(
    lint_state: LintState,
    source_root: str,
    lines: List[str],
) -> None:
    if not _is_project_trusted(source_root):
        return None
    try:
        make_res = subprocess.run(
            ["make", "--dry-run", "-f", "-", "debhelper-fail-me"],
            input="".join(lines).encode("utf-8"),
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
            cwd=source_root,
            timeout=1,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    else:
        if make_res.returncode != 0:
            make_output = make_res.stderr.decode("utf-8")
            m = _MAKE_ERROR_RE.match(make_output)
            if m:
                # We want it zero-based and make reports it one-based
                line_of_error = int(m.group(1)) - 1
                msg = m.group(2).strip()
                error_range = TERange(
                    TEPosition(
                        line_of_error,
                        0,
                    ),
                    TEPosition(
                        line_of_error + 1,
                        0,
                    ),
                )
                lint_state.emit_diagnostic(
                    error_range,
                    f"make error: {msg}",
                    "error",
                    "debputy",
                )
    return


def iter_make_lines(
    lint_state: LintState,
    lines: List[str],
) -> Iterator[Tuple[int, str]]:
    skip_next_line = False
    is_extended_comment = False
    for line_no, line in enumerate(lines):
        skip_this = skip_next_line
        skip_next_line = False
        if line.rstrip().endswith("\\"):
            skip_next_line = True

        if skip_this:
            if is_extended_comment and lint_state.debputy_config.config_value(
                DCO_SPELLCHECK_COMMENTS
            ):
                spellcheck_line(lint_state, line_no, line)
            continue

        if line.startswith("#"):
            if lint_state.debputy_config.config_value(DCO_SPELLCHECK_COMMENTS):
                spellcheck_line(lint_state, line_no, line)
            is_extended_comment = skip_next_line
            continue
        is_extended_comment = False

        if line.startswith("\t") or line.isspace():
            continue

        is_extended_comment = False
        # We are not really dealing with extension lines at the moment (other than for spellchecking),
        # since nothing needs it
        yield line_no, line


def _forbidden_hook_targets(dh_commands: DhListCommands) -> FrozenSet[str]:
    if not dh_commands.disabled_commands:
        return frozenset()
    return frozenset(
        itertools.chain.from_iterable(
            _as_hook_targets(c) for c in dh_commands.disabled_commands
        )
    )


@lint_diagnostics(_DISPATCH_RULE)
async def _lint_debian_rules(lint_state: LintState) -> None:
    lines = lint_state.lines
    path = lint_state.path
    source_root = os.path.dirname(os.path.dirname(path))
    if source_root == "":
        source_root = "."

    _run_make_dryrun(lint_state, source_root, lines)
    dh_sequencer_data = lint_state.dh_sequencer_data
    dh_sequences = dh_sequencer_data.sequences
    dh_commands = resolve_active_and_inactive_dh_commands(
        dh_sequences,
        source_root=source_root,
    )
    if dh_commands.active_commands:
        all_hook_targets = {
            ht for c in dh_commands.active_commands for ht in _as_hook_targets(c)
        }
        all_hook_targets.update(_KNOWN_TARGETS)
    else:
        all_hook_targets = _KNOWN_TARGETS

    missing_targets = {}
    forbidden_hook_targets = _forbidden_hook_targets(dh_commands)
    all_allowed_hook_targets = all_hook_targets - forbidden_hook_targets

    for line_no, line in iter_make_lines(lint_state, lines):
        try:
            colon_idx = line.index(":")
            if len(line) > colon_idx + 1 and line[colon_idx + 1] == "=":
                continue
        except ValueError:
            continue
        target_substring = line[0:colon_idx]
        if "=" in target_substring or "$(for" in target_substring:
            continue
        for i, m in enumerate(_WORDS_RE.finditer(target_substring)):
            target = m.group(1)
            if i == 0 and (target in _COMMAND_WORDS or target.startswith("(")):
                break
            if "%" in target or "$" in target:
                continue
            if target in forbidden_hook_targets:
                pos, endpos = m.span(1)
                r = TERange(
                    TEPosition(
                        line_no,
                        pos,
                    ),
                    TEPosition(
                        line_no,
                        endpos,
                    ),
                )
                lint_state.emit_diagnostic(
                    r,
                    f"The hook target {target} will not be run due to dh compat level or chosen dh add-ons.",
                    "error",
                    "debputy",
                )
                continue

            if target in all_allowed_hook_targets or target in missing_targets:
                continue
            pos, endpos = m.span(1)
            hook_location = line_no, pos, endpos
            missing_targets[target] = hook_location

    for target, (line_no, pos, endpos) in missing_targets.items():
        candidates = detect_possible_typo(target, all_allowed_hook_targets)
        if not candidates and not target.startswith(
            ("override_", "execute_before_", "execute_after_")
        ):
            continue

        r = TERange(
            TEPosition(
                line_no,
                pos,
            ),
            TEPosition(
                line_no,
                endpos,
            ),
        )
        if candidates:
            msg = f"Target {target} looks like a typo of a known target"
        else:
            msg = f"Unknown rules dh hook target {target}"
        if candidates:
            fixes = [propose_correct_text_quick_fix(c) for c in candidates]
        else:
            fixes = []
        lint_state.emit_diagnostic(
            r,
            msg,
            "warning",
            "debputy",
            quickfixes=fixes,
        )


@lsp_completer(_DISPATCH_RULE)
def debian_rules_completions(
    ls: "DebputyLanguageServer",
    params: CompletionParams,
) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
    doc = ls.workspace.get_text_document(params.text_document.uri)
    lines = doc.lines
    server_position = doc.position_codec.position_from_client_units(
        lines, params.position
    )

    line = lines[server_position.line]
    line_start = line[0 : server_position.character]

    if _CONTAINS_TAB_OR_COLON.search(line_start):
        return None

    if line_start.startswith(("include ", "-include ")):
        parts = line_start.split(maxsplit=2)
        included = parts[1] if len(parts) > 1 else ""
        # Ignore cases with variables (such as $(foo)), since our suggestion will
        # never match it, and likely the user wanted something fancy that we
        # cannot provide.
        if (
            "$" not in line_start
            and len(parts) <= 2
            and included not in _STANDARD_MAKEFILES
        ):
            return [CompletionItem(p) for p in _STANDARD_MAKEFILES]
        return None

    source_root = os.path.dirname(os.path.dirname(doc.path))
    dh_sequencer_data = ls.lint_state(doc).dh_sequencer_data
    dh_sequences = dh_sequencer_data.sequences
    dh_commands = resolve_active_and_inactive_dh_commands(
        dh_sequences,
        source_root=source_root,
    )
    if not dh_commands.active_commands:
        return None
    items = [
        CompletionItem(ht)
        for c in dh_commands.active_commands
        for ht in _as_hook_targets(c)
    ]
    return items
