import textwrap
from typing import (
    Union,
    Any,
    Optional,
    List,
    Tuple,
    TYPE_CHECKING,
    get_origin,
    Literal,
    get_args,
    Generic,
    cast,
)
from collections.abc import Iterable, Callable, Sequence, Iterator

from debputy.commands.debputy_cmd.output import OutputStyle
from debputy.linting.lint_util import LintState
from debputy.lsp.diagnostics import LintSeverity
from debputy.lsp.quickfixes import propose_correct_text_quick_fix
from debian._deb822_repro.locatable import (
    Position as TEPosition,
    Range as TERange,
)
from debputy.manifest_parser.declarative_parser import (
    DeclarativeMappingInputParser,
    ParserGenerator,
    AttributeDescription,
    DeclarativeNonMappingInputParser,
    BASIC_SIMPLE_TYPES,
)
from debputy.manifest_parser.parser_doc import (
    render_rule,
    render_attribute_doc,
    doc_args_for_parser_doc,
)
from debputy.manifest_parser.tagging_types import DebputyDispatchableType
from debputy.manifest_parser.util import AttributePath
from debputy.plugin.api.feature_set import PluginProvidedFeatureSet
from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin
from debputy.plugin.api.impl_types import (
    DebputyPluginMetadata,
    DeclarativeInputParser,
    DispatchingParserBase,
    InPackageContextParser,
    ListWrappedDeclarativeInputParser,
    PluginProvidedParser,
    DeclarativeValuelessKeywordInputParser,
    DispatchingTableParser,
)
from debputy.substitution import VariableContext
from debputy.util import _info, _warn, detect_possible_typo, T
from debputy.yaml import MANIFEST_YAML
from debputy.yaml.compat import (
    MarkedYAMLError,
    YAMLError,
)
from debputy.yaml.compat import (
    Node,
    CommentedMap,
    LineCol,
    CommentedSeq,
    CommentedBase,
)

if TYPE_CHECKING:
    import lsprotocol.types as types
else:
    import debputy.lsprotocol.types as types

try:
    from pygls.server import LanguageServer
    from debputy.lsp.debputy_ls import DebputyLanguageServer
except ImportError:
    pass


YAML_COMPLETION_HINT_KEY = "___COMPLETE:"
YAML_COMPLETION_HINT_VALUE = "___COMPLETE"
DEBPUTY_PLUGIN_METADATA = plugin_metadata_for_debputys_own_plugin()


class LSPYAMLHelper(Generic[T]):

    def __init__(
        self,
        lint_state: LintState,
        pg: ParserGenerator,
        custom_data: T,
    ) -> None:
        self.lint_state = lint_state
        self.lines = _lines(lint_state.lines)
        self.pg = pg
        self.custom_data = custom_data

    def _validate_subparser_is_valid_here(
        self,
        subparser: PluginProvidedParser,
        orig_key: str,
        line: int,
        col: int,
    ) -> None:
        # Subclasses can provide custom logic here
        pass

    def _lint_dispatch_parser(
        self,
        parser: DispatchingParserBase,
        dispatch_key: str,
        key_pos: tuple[int, int] | None,
        value: Any | None,
        value_pos: tuple[int, int] | None,
        *,
        is_keyword_only: bool,
    ) -> None:
        is_known = parser.is_known_keyword(dispatch_key)
        orig_key = dispatch_key
        if not is_known and key_pos is not None:

            if value is None:
                opts = {
                    "message_format": 'Unknown or unsupported value "{key}".',
                }
            else:
                opts = {}
            corrected_key = yaml_flag_unknown_key(
                self.lint_state,
                dispatch_key,
                parser.registered_keywords(),
                key_pos,
                unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity,
                **opts,
            )
            if corrected_key is not None:
                dispatch_key = corrected_key
                is_known = True

        if is_known:
            subparser = parser.parser_for(dispatch_key)
            assert subparser is not None
            if key_pos:
                line, col = key_pos
                self._validate_subparser_is_valid_here(
                    subparser,
                    orig_key,
                    line,
                    col,
                )

            if isinstance(subparser.parser, DeclarativeValuelessKeywordInputParser):
                if value is not None or not is_keyword_only:
                    if value is not None:
                        line_no, cursor_pos = value_pos if value_pos else key_pos
                        value_range = self._remaining_line(line_no, cursor_pos)
                        if _is_empty_range(value_range):
                            # In the unlikely case that the value position is present but leads to
                            # an empty range, report the key instead.
                            line_no, cursor_pos = key_pos
                            value_range = self._remaining_line(line_no, cursor_pos)
                        msg = f"The keyword {dispatch_key} does not accept any value"
                    else:
                        line_no, cursor_pos = key_pos
                        value_range = self._remaining_line(line_no, cursor_pos)
                        msg = f"The keyword {dispatch_key} cannot be used as a mapping key"

                    assert not _is_empty_range(value_range)
                    self.lint_state.emit_diagnostic(
                        value_range,
                        msg,
                        "error",
                        "debputy",
                    )
                return

            self.lint_content(
                # Pycharm's type checking gets confused by the isinstance check above.
                cast("DeclarativeInputParser[Any]", subparser.parser),
                value,
                key=orig_key,
                content_pos=value_pos,
            )

    def lint_content(
        self,
        parser: DeclarativeInputParser[Any],
        content: Any,
        *,
        key: str | int | None = None,
        content_pos: tuple[int, int] | None = None,
    ) -> None:
        if isinstance(parser, DispatchingParserBase):
            if isinstance(content, str):
                self._lint_dispatch_parser(
                    parser,
                    content,
                    content_pos,
                    None,
                    None,
                    is_keyword_only=True,
                )

                return
            if not isinstance(content, CommentedMap):
                return
            lc = content.lc
            for dispatch_key, value in content.items():
                key_pos = lc.key(dispatch_key)
                value_pos = lc.value(dispatch_key)
                self._lint_dispatch_parser(
                    parser,
                    dispatch_key,
                    key_pos,
                    value,
                    value_pos,
                    is_keyword_only=False,
                )

        elif isinstance(parser, ListWrappedDeclarativeInputParser):
            if not isinstance(content, CommentedSeq):
                return
            subparser = parser.delegate
            lc = content.lc
            for idx, value in enumerate(content):
                value_pos = lc.item(idx)
                self.lint_content(subparser, value, content_pos=value_pos, key=idx)
        elif isinstance(parser, InPackageContextParser):
            if not isinstance(content, CommentedMap):
                return
            known_packages = self.lint_state.binary_packages
            lc = content.lc
            for k, v in content.items():
                if k is None or (
                    "{{" not in k
                    and known_packages is not None
                    and k not in known_packages
                ):
                    yaml_flag_unknown_key(
                        self.lint_state,
                        k,
                        known_packages,
                        lc.key(k),
                        message_format='Unknown package "{key}".',
                    )
                self.lint_content(parser.delegate, v, key=k, content_pos=lc.value(k))
        elif isinstance(parser, DeclarativeMappingInputParser):
            self._lint_declarative_mapping_input_parser(
                parser,
                content,
                content_pos,
                key=key,
            )
        elif isinstance(parser, DeclarativeNonMappingInputParser):
            if content_pos is not None:
                self._lint_attr_value(
                    parser.alt_form_parser,
                    key,
                    content,
                    content_pos,
                )

    def _lint_declarative_mapping_input_parser(
        self,
        parser: DeclarativeMappingInputParser,
        content: Any,
        content_pos: tuple[int, int],
        *,
        key: str | int | None = None,
    ) -> None:
        if not isinstance(content, CommentedMap):
            alt_form_parser = parser.alt_form_parser
            if alt_form_parser:
                self._lint_attr_value(
                    alt_form_parser,
                    key,
                    content,
                    content_pos,
                )
            else:
                line_no, cursor_pos = content_pos
                value_range = self._remaining_line(line_no, cursor_pos)
                if _is_empty_range(value_range):
                    # FIXME: We cannot report an empty range, but there is still a problem here.
                    return
                if isinstance(key, str):
                    msg = f'The value for "{key}" must be a mapping'
                else:
                    msg = "The value must be a mapping"
                self.lint_state.emit_diagnostic(
                    value_range,
                    msg,
                    "error",
                    "debputy",
                )
            return
        lc = content.lc
        for key, value in content.items():
            attr = parser.manifest_attributes.get(key)
            key_pos = lc.key(key)
            value_pos = lc.value(key)
            if attr is None:
                corrected_key = yaml_flag_unknown_key(
                    self.lint_state,
                    key,
                    parser.manifest_attributes,
                    key_pos,
                )
                if corrected_key:
                    key = corrected_key
                    attr = parser.manifest_attributes.get(corrected_key)
            if attr is None:
                continue

            self._lint_attr_value(
                attr,
                key,
                value,
                value_pos,
            )

            for forbidden_key in attr.conflicting_attributes:
                if forbidden_key in content:
                    line, col = key_pos
                    con_line, con_col = lc.key(forbidden_key)
                    yaml_conflicting_key(
                        self.lint_state,
                        key,
                        forbidden_key,
                        line,
                        col,
                        con_line,
                        con_col,
                    )
        for mx in parser.mutually_exclusive_attributes:
            matches = content.keys() & mx
            if len(matches) < 2:
                continue
            key, *others = list(matches)
            line, col = lc.key(key)
            for other in others:
                con_line, con_col = lc.key(other)
                yaml_conflicting_key(
                    self.lint_state,
                    key,
                    other,
                    line,
                    col,
                    con_line,
                    con_col,
                )

    def _type_based_value_check(
        self,
        target_attr_type: type,
        value: Any,
        value_pos: tuple[int, int],
        *,
        key: str | int | None = None,
    ) -> bool:
        if issubclass(target_attr_type, DebputyDispatchableType):
            parser = self.pg.dispatch_parser_table_for(target_attr_type)
            self.lint_content(
                parser,
                value,
                key=key,
                content_pos=value_pos,
            )
            return True
        return False

    def _lint_attr_value(
        self,
        attr: AttributeDescription,
        key: str | int | None,
        value: Any,
        pos: tuple[int, int],
    ) -> None:
        target_attr_type = attr.attribute_type
        orig = get_origin(target_attr_type)
        if orig == list and isinstance(value, CommentedSeq):
            lc = value.lc
            target_item_type = get_args(target_attr_type)[0]
            for idx, v in enumerate(value):
                v_pos = lc.item(idx)
                self._lint_value(
                    idx,
                    v,
                    target_item_type,
                    v_pos,
                )

        else:
            self._lint_value(
                key,
                value,
                target_attr_type,
                pos,
            )

    def _lint_value(
        self,
        key: str | int | None,
        value: Any,
        target_attr_type: Any,
        pos: tuple[int, int],
    ) -> None:
        type_mapping = self.pg.get_mapped_type_from_target_type(target_attr_type)
        source_attr_type = target_attr_type
        if type_mapping is not None:
            source_attr_type = type_mapping.source_type
        valid_values: Sequence[Any] | None = None
        orig = get_origin(source_attr_type)
        if orig == Literal:
            valid_values = get_args(target_attr_type)
        elif orig == bool or target_attr_type == bool:
            valid_values = (True, False)
        elif isinstance(target_attr_type, type) and self._type_based_value_check(
            target_attr_type,
            value,
            pos,
            key=key,
        ):
            return
        elif source_attr_type in BASIC_SIMPLE_TYPES:
            if isinstance(value, source_attr_type):
                return
            expected_type = BASIC_SIMPLE_TYPES[source_attr_type]
            line_no, cursor_pos = pos
            value_range = self._remaining_line(line_no, cursor_pos)
            if _is_empty_range(value_range):
                # FIXME: We cannot report an empty range, but there is still a problem here.
                return
            if isinstance(key, str):
                msg = f'Value for "{key}" does not match the base type: Expected {expected_type}'
            else:
                msg = f"Value does not match the base type: Expected {expected_type}"
            if issubclass(source_attr_type, str):
                quickfixes = [
                    propose_correct_text_quick_fix(_as_yaml_value(str(value)))
                ]
            else:
                quickfixes = None
            self.lint_state.emit_diagnostic(
                value_range,
                msg,
                "error",
                "debputy",
                quickfixes=quickfixes,
            )
            return

        if valid_values is None or value in valid_values:
            return
        line_no, cursor_pos = pos
        value_range = self._remaining_line(line_no, cursor_pos)
        if _is_empty_range(value_range):
            # FIXME: We cannot report an empty range, but there is still a problem here.
            return
        if isinstance(key, str):
            msg = f'Not a supported value for "{key}"'
        else:
            msg = "Not a supported value here"
        self.lint_state.emit_diagnostic(
            value_range,
            msg,
            "error",
            "debputy",
            quickfixes=[
                propose_correct_text_quick_fix(_as_yaml_value(m)) for m in valid_values
            ],
        )

    def _remaining_line(self, line_no: int, pos_start: int) -> "TERange":
        raw_line = self.lines[line_no].rstrip()
        pos_end = len(raw_line)
        return TERange(
            TEPosition(
                line_no,
                pos_start,
            ),
            TEPosition(
                line_no,
                pos_end,
            ),
        )


def _is_empty_range(token_range: "TERange") -> bool:
    return token_range.start_pos == token_range.end_pos


def _lines(lines: list[str]) -> list[str]:
    if not lines or lines[-1].endswith("\n"):
        lines = lines.copy()
        lines.append("")
    return lines


async def generic_yaml_lint(
    lint_state: LintState,
    root_parser: DeclarativeInputParser[Any],
    initialize_yaml_helper: Callable[[LintState], LSPYAMLHelper[Any]],
) -> None:
    lines = _lines(lint_state.lines)
    try:
        content = MANIFEST_YAML.load(lint_state.content)
    except MarkedYAMLError as e:
        if e.context_mark:
            line = e.context_mark.line
            column = e.context_mark.column
        else:
            line = e.problem_mark.line
            column = e.problem_mark.column
        error_range = error_range_at_position(
            lines,
            line,
            column,
        )
        lint_state.emit_diagnostic(
            error_range,
            f"YAML parse error: {e}",
            "error",
            "debputy",
        )
    except YAMLError as e:
        error_range = TERange(
            TEPosition(0, 0),
            TEPosition(0, len(lines[0])),
        )
        lint_state.emit_diagnostic(
            error_range,
            f"Unknown YAML parse error: {e} [{e!r}]",
            "error",
            "debputy",
        )
    else:
        yaml_linter = initialize_yaml_helper(lint_state)
        yaml_linter.lint_content(
            root_parser,
            content,
        )


def _as_yaml_value(v: Any) -> str:
    if isinstance(v, bool):
        return str(v).lower()
    if isinstance(v, str):
        return maybe_quote_yaml_value(str(v))
    return str(v)


def resolve_hover_text_for_value(
    feature_set: PluginProvidedFeatureSet,
    parser: DeclarativeMappingInputParser,
    plugin_metadata: DebputyPluginMetadata,
    output_style: OutputStyle,
    show_integration_mode: bool,
    segment: str | int,
    matched: Any,
) -> str | None:

    hover_doc_text: str | None = None
    attr = parser.manifest_attributes.get(segment)
    attr_type = attr.attribute_type if attr is not None else None
    if attr_type is None:
        _info(f"Matched value for {segment} -- No attr or type")
        return None
    if isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType):
        parser_generator = feature_set.manifest_parser_generator
        parser = parser_generator.dispatch_parser_table_for(attr_type)
        if parser is None or not isinstance(matched, str):
            _info(
                f"Unknown parser for {segment} or matched is not a str -- {attr_type} {type(matched)=}"
            )
            return None
        subparser = parser.parser_for(matched)
        if subparser is None:
            _info(f"Unknown parser for {matched} (subparser)")
            return None
        hover_doc_text = render_rule(
            matched,
            subparser.parser,
            plugin_metadata,
            output_style,
            show_integration_mode=show_integration_mode,
        )
    else:
        _info(f"Unknown value: {matched} -- {segment}")
    return hover_doc_text


def resolve_hover_text(
    feature_set: PluginProvidedFeatureSet,
    parser: DeclarativeInputParser[Any] | DispatchingParserBase | None,
    plugin_metadata: DebputyPluginMetadata,
    output_style: OutputStyle,
    show_integration_mode: bool,
    segments: list[str | int],
    at_depth_idx: int,
    matched: Any,
    matched_key: bool,
) -> str | None:
    hover_doc_text: str | None = None
    if at_depth_idx == len(segments):
        segment = segments[at_depth_idx - 1]
        _info(f"Matched {segment} at ==, {matched_key=} ")
        hover_doc_text = render_rule(
            segment,
            parser,
            plugin_metadata,
            output_style,
            is_root_rule=False,
            show_integration_mode=show_integration_mode,
        )
    elif at_depth_idx + 1 == len(segments) and isinstance(
        parser, DeclarativeMappingInputParser
    ):
        segment = segments[at_depth_idx]
        _info(f"Matched {segment} at -1, {matched_key=} ")
        if isinstance(segment, str):
            if not matched_key:
                hover_doc_text = resolve_hover_text_for_value(
                    feature_set,
                    parser,
                    plugin_metadata,
                    output_style,
                    show_integration_mode,
                    segment,
                    matched,
                )
            if matched_key or hover_doc_text is None:
                rule_name = _guess_rule_name(segments, at_depth_idx)
                hover_doc_text = _render_param_doc(
                    rule_name,
                    parser,
                    plugin_metadata,
                    segment,
                )
    else:
        _info(f"No doc: {at_depth_idx=} {len(segments)=}")

    return hover_doc_text


def as_hover_doc(
    ls: "DebputyLanguageServer",
    hover_doc_text: str | None,
) -> types.Hover | None:
    if hover_doc_text is None:
        return None
    return types.Hover(
        contents=types.MarkupContent(
            kind=ls.hover_markup_format(
                types.MarkupKind.Markdown,
                types.MarkupKind.PlainText,
            ),
            value=hover_doc_text,
        ),
    )


def _render_param_doc(
    rule_name: str,
    declarative_parser: DeclarativeMappingInputParser,
    plugin_metadata: DebputyPluginMetadata,
    attribute: str,
) -> str | None:
    attr = declarative_parser.source_attributes.get(attribute)
    if attr is None:
        return None

    doc_args, parser_doc = doc_args_for_parser_doc(
        rule_name,
        declarative_parser,
        plugin_metadata,
    )
    rendered_docs = render_attribute_doc(
        declarative_parser,
        declarative_parser.source_attributes,
        declarative_parser.input_time_required_parameters,
        declarative_parser.at_least_one_of,
        parser_doc,
        doc_args,
        is_interactive=True,
        rule_name=rule_name,
    )

    for attributes, rendered_doc in rendered_docs:
        if attribute in attributes:
            full_doc = [
                f"# Attribute `{attribute}`",
                "",
            ]
            full_doc.extend(rendered_doc)

            return "\n".join(full_doc)
    return None


def _guess_rule_name(segments: list[str | int], idx: int) -> str:
    orig_idx = idx
    idx -= 1
    while idx >= 0:
        segment = segments[idx]
        if isinstance(segment, str):
            return segment
        idx -= 1
    _warn(f"Unable to derive rule name from {segments} [{orig_idx}]")
    return "<Bug: unknown rule name>"


def is_at(position: types.Position, lc_pos: tuple[int, int]) -> bool:
    return position.line == lc_pos[0] and position.character == lc_pos[1]


def is_before(position: types.Position, lc_pos: tuple[int, int]) -> bool:
    line, column = lc_pos
    if position.line < line:
        return True
    if position.line == line and position.character < column:
        return True
    return False


def is_after(position: types.Position, lc_pos: tuple[int, int]) -> bool:
    line, column = lc_pos
    if position.line > line:
        return True
    if position.line == line and position.character > column:
        return True
    return False


def error_range_at_position(
    lines: list[str],
    line_no: int,
    char_offset: int,
) -> TERange:
    line = lines[line_no]
    line_len = len(line)
    start_idx = char_offset
    end_idx = start_idx

    if line[start_idx].isspace():

        def _check(x: str) -> bool:
            return not x.isspace()

    else:

        def _check(x: str) -> bool:
            return x.isspace()

    for i in range(end_idx, line_len):
        end_idx = i
        if _check(line[i]):
            break

    for i in range(start_idx, -1, -1):
        if i > 0 and _check(line[i]):
            break
        start_idx = i

    return TERange(
        TEPosition(line_no, start_idx),
        TEPosition(line_no, end_idx),
    )


def _escape(v: str) -> str:
    return '"' + v.replace("\n", "\\n") + '"'


def insert_complete_marker_snippet(
    lines: list[str],
    server_position: types.Position,
) -> bool:
    _info(f"Complete at {server_position}")
    line_no = server_position.line
    line = lines[line_no] if line_no < len(lines) else ""

    lhs_ws = line[: server_position.character]
    lhs = lhs_ws.strip()
    open_quote = ""
    rhs = line[server_position.character + 1 :]
    for q in ('"', "'"):
        if rhs.endswith(q):
            break
        qc = lhs.count(q) & 1
        if qc:
            open_quote = q
            break

    if lhs.endswith(":"):
        _info("Insertion of value (key seen)")
        new_line = (
            line[: server_position.character]
            + YAML_COMPLETION_HINT_VALUE
            + f"{open_quote}\n"
        )
    elif lhs.startswith("-"):
        _info("Insertion of key or value (list item)")
        # Respect the provided indentation
        snippet = (
            YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE
        )
        new_line = line[: server_position.character] + snippet + f"{open_quote}\n"
    elif not lhs or (lhs_ws and not lhs_ws[0].isspace()):
        _info(f"Insertion of key or value: {_escape(line[server_position.character:])}")
        # Respect the provided indentation
        snippet = (
            YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE
        )
        new_line = line[: server_position.character] + snippet + f"{open_quote}\n"
    elif lhs.isalpha() and ":" not in lhs:
        _info(f"Expanding value to a key: {_escape(line[server_position.character:])}")
        # Respect the provided indentation
        new_line = (
            line[: server_position.character]
            + YAML_COMPLETION_HINT_KEY
            + f"{open_quote}\n"
        )
    elif open_quote:
        _info(
            f"Expanding value inside a string: {_escape(line[server_position.character:])}"
        )
        new_line = (
            line[: server_position.character]
            + YAML_COMPLETION_HINT_VALUE
            + f"{open_quote}\n"
        )
    else:
        c = (
            line[server_position.character]
            if server_position.character < len(line)
            else "(OOB)"
        )
        _info(f"Not touching line: {_escape(line)} -- {_escape(c)}")
        return False
    _info(f'Evaluating complete on synthetic line: "{new_line}"')
    if line_no < len(lines):
        lines[line_no] = new_line
    elif line_no == len(lines):
        lines.append(new_line)
    else:
        return False
    return True


def _keywords_with_parser(
    parser: DeclarativeMappingInputParser | DispatchingParserBase,
) -> Iterator[tuple[str, PluginProvidedParser]]:
    for keyword in parser.registered_keywords():
        pp_subparser = parser.parser_for(keyword)
        yield keyword, pp_subparser


def yaml_key_range(
    key: str | None,
    line: int,
    col: int,
) -> "TERange":
    key_len = len(key) if key else 1
    return TERange.between(
        TEPosition(line, col),
        TEPosition(line, col + key_len),
    )


def yaml_flag_unknown_key(
    lint_state: LintState,
    key: str | None,
    expected_keys: Iterable[str],
    key_pos: tuple[int, int],
    *,
    message_format: str = 'Unknown or unsupported key "{key}".',
    unknown_keys_diagnostic_severity: LintSeverity | None = "error",
) -> str | None:
    line, col = key_pos
    key_range = yaml_key_range(key, line, col)

    candidates = detect_possible_typo(key, expected_keys) if key is not None else ()
    extra = ""
    corrected_key = None
    if candidates:
        extra = f' It looks like a typo of "{candidates[0]}".'
        # TODO: We should be able to tell that `install-doc` and `install-docs` are the same.
        #  That would enable this to work in more cases.
        corrected_key = candidates[0] if len(candidates) == 1 else None
        if unknown_keys_diagnostic_severity is None:
            message_format = f"Possible typo of {candidates[0]}."
            extra = ""
    elif unknown_keys_diagnostic_severity is None:
        return None

    if key is None:
        message_format = "Missing key"
    if unknown_keys_diagnostic_severity is not None:
        lint_state.emit_diagnostic(
            key_range,
            message_format.format(key=key) + extra,
            unknown_keys_diagnostic_severity,
            "debputy",
            quickfixes=[propose_correct_text_quick_fix(n) for n in candidates],
        )
    return corrected_key


def yaml_conflicting_key(
    lint_state: LintState,
    key_a: str,
    key_b: str,
    key_a_line: int,
    key_a_col: int,
    key_b_line: int,
    key_b_col: int,
) -> None:
    key_a_range = TERange(
        TEPosition(
            key_a_line,
            key_a_col,
        ),
        TEPosition(
            key_a_line,
            key_a_col + len(key_a),
        ),
    )
    key_b_range = TERange(
        TEPosition(
            key_b_line,
            key_b_col,
        ),
        TEPosition(
            key_b_line,
            key_b_col + len(key_b),
        ),
    )
    lint_state.emit_diagnostic(
        key_a_range,
        f'The "{key_a}" cannot be used with "{key_b}".',
        "error",
        "debputy",
        related_information=[
            lint_state.related_diagnostic_information(
                key_b_range, f'The attribute "{key_b}" is used here.'
            ),
        ],
    )

    lint_state.emit_diagnostic(
        key_b_range,
        f'The "{key_b}" cannot be used with "{key_a}".',
        "error",
        "debputy",
        related_information=[
            lint_state.related_diagnostic_information(
                key_a_range,
                f'The attribute "{key_a}" is used here.',
            ),
        ],
    )


def resolve_keyword(
    current_parser: DeclarativeInputParser[Any] | DispatchingParserBase,
    current_plugin: DebputyPluginMetadata,
    segments: list[str | int],
    segment_idx: int,
    parser_generator: ParserGenerator,
    *,
    is_completion_attempt: bool = False,
) -> None | (
    tuple[
        DeclarativeInputParser[Any] | DispatchingParserBase,
        DebputyPluginMetadata,
        int,
    ]
):
    if segment_idx >= len(segments):
        return current_parser, current_plugin, segment_idx
    current_segment = segments[segment_idx]
    if isinstance(current_parser, ListWrappedDeclarativeInputParser):
        if isinstance(current_segment, int):
            current_parser = current_parser.delegate
            segment_idx += 1
            if segment_idx >= len(segments):
                return current_parser, current_plugin, segment_idx
            current_segment = segments[segment_idx]

    if not isinstance(current_segment, str):
        return None

    if is_completion_attempt and current_segment.endswith(
        (YAML_COMPLETION_HINT_KEY, YAML_COMPLETION_HINT_VALUE)
    ):
        return current_parser, current_plugin, segment_idx

    if isinstance(current_parser, InPackageContextParser):
        return resolve_keyword(
            current_parser.delegate,
            current_plugin,
            segments,
            segment_idx + 1,
            parser_generator,
            is_completion_attempt=is_completion_attempt,
        )
    elif isinstance(current_parser, DispatchingParserBase):
        if not current_parser.is_known_keyword(current_segment):
            if is_completion_attempt:
                return current_parser, current_plugin, segment_idx
            return None
        subparser = current_parser.parser_for(current_segment)
        segment_idx += 1
        if segment_idx < len(segments):
            return resolve_keyword(
                subparser.parser,
                subparser.plugin_metadata,
                segments,
                segment_idx,
                parser_generator,
                is_completion_attempt=is_completion_attempt,
            )
        return subparser.parser, subparser.plugin_metadata, segment_idx
    elif isinstance(current_parser, DeclarativeMappingInputParser):
        attr = current_parser.manifest_attributes.get(current_segment)
        attr_type = attr.attribute_type if attr is not None else None
        if (
            attr_type is not None
            and isinstance(attr_type, type)
            and issubclass(attr_type, DebputyDispatchableType)
        ):
            subparser = parser_generator.dispatch_parser_table_for(attr_type)
            if subparser is not None and (
                is_completion_attempt or segment_idx + 1 < len(segments)
            ):
                return resolve_keyword(
                    subparser,
                    current_plugin,
                    segments,
                    segment_idx + 1,
                    parser_generator,
                    is_completion_attempt=is_completion_attempt,
                )
        return current_parser, current_plugin, segment_idx
    else:
        _info(f"Unknown parser: {current_parser.__class__}")
    return None


def _trace_cursor(
    content: Any,
    attribute_path: AttributePath,
    server_position: types.Position,
) -> tuple[bool, AttributePath, Any, Any] | None:
    matched_key: str | int | None = None
    matched: Node | None = None
    matched_was_key: bool = False

    if isinstance(content, CommentedMap):
        dict_lc: LineCol = content.lc
        for k, v in content.items():
            k_lc = dict_lc.key(k)
            if is_before(server_position, k_lc):
                break
            v_lc = dict_lc.value(k)
            if is_before(server_position, v_lc):
                # TODO: Handle ":" and "whitespace"
                matched = k
                matched_key = k
                matched_was_key = True
                break
            matched = v
            matched_key = k
    elif isinstance(content, CommentedSeq):
        list_lc: LineCol = content.lc
        for idx, value in enumerate(content):
            i_lc = list_lc.item(idx)
            if is_before(server_position, i_lc):
                break
            matched_key = idx
            matched = value

    if matched is not None:
        assert matched_key is not None
        sub_path = attribute_path[matched_key]
        if not matched_was_key and isinstance(matched, CommentedBase):
            return _trace_cursor(matched, sub_path, server_position)
        return matched_was_key, sub_path, matched, content
    return None


def maybe_quote_yaml_value(v: str) -> str:
    if v and v[0].isdigit():
        try:
            float(v)
            return f"'{v}'"
        except ValueError:
            pass
    return v


def _complete_value(v: Any) -> str:
    if isinstance(v, str):
        return maybe_quote_yaml_value(v)
    return str(v)


def completion_from_attr(
    attr: AttributeDescription,
    pg: ParserGenerator,
    matched: Any,
    *,
    matched_key: bool = False,
    has_colon: bool = False,
) -> types.CompletionList | Sequence[types.CompletionItem] | None:
    type_mapping = pg.get_mapped_type_from_target_type(attr.attribute_type)
    if type_mapping is not None:
        attr_type = type_mapping.source_type
    else:
        attr_type = attr.attribute_type

    orig = get_origin(attr_type)
    valid_values: Sequence[Any] = tuple()

    if orig == Literal:
        valid_values = get_args(attr_type)
    elif orig == bool or attr.attribute_type == bool:
        valid_values = ("true", "false")
    elif isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType):
        parser: DispatchingTableParser[Any] = pg.dispatch_parser_table_for(attr_type)
        if parser is None:
            return None
        valid_values = [
            k if has_colon or not matched_key else f"{k}:"
            for k in parser.registered_keywords()
            if isinstance(
                parser.parser_for(k).parser, DeclarativeValuelessKeywordInputParser
            )
            ^ matched_key
        ]

    if matched in valid_values:
        _info(f"Already filled: {matched} is one of {valid_values}")
        return None
    if valid_values:
        return [types.CompletionItem(_complete_value(x)) for x in valid_values]
    return None


def completion_item(
    quoted_keyword: str,
    pp_subparser: PluginProvidedParser,
) -> types.CompletionItem:
    inline_reference_documentation = pp_subparser.parser.inline_reference_documentation
    synopsis = (
        inline_reference_documentation.synopsis
        if inline_reference_documentation
        else None
    )
    return types.CompletionItem(
        quoted_keyword,
        detail=synopsis,
    )


def _is_inside_manifest_variable_substitution(
    lines: list[str],
    server_position: types.Position,
) -> bool:

    current_line = lines[server_position.line]
    try:
        open_idx = current_line[0 : server_position.character].rindex("{{")
        return "}}" not in current_line[open_idx : server_position.character]
    except ValueError:
        return False


def _manifest_substitution_variable_at_position(
    lines: list[str],
    server_position: types.Position,
) -> str | None:
    current_line = lines[server_position.line]
    try:
        open_idx = current_line[0 : server_position.character].rindex("{{") + 2
        if "}}" in current_line[open_idx : server_position.character]:
            return None
        variable_len = current_line[open_idx:].index("}}")
        close_idx = open_idx + variable_len
    except ValueError as e:
        return None
    return current_line[open_idx:close_idx]


def _insert_complete_marker_and_parse_yaml(
    lines: list[str],
    server_position: types.Position,
) -> Any | None:
    added_key = insert_complete_marker_snippet(lines, server_position)
    attempts = 1 if added_key else 2
    content = None
    while attempts > 0:
        attempts -= 1
        try:
            # Since we mutated the lines to insert a token, `doc.source` cannot
            # be used here.
            content = MANIFEST_YAML.load("".join(lines))
            break
        except MarkedYAMLError as e:
            context_line = (
                e.context_mark.line if e.context_mark else e.problem_mark.line
            )
            if (
                e.problem_mark.line != server_position.line
                and context_line != server_position.line
            ):
                l_data = (
                    lines[e.problem_mark.line].rstrip()
                    if e.problem_mark.line < len(lines)
                    else "N/A (OOB)"
                )

                _info(f"Parse error on line: {e.problem_mark.line}: {l_data}")
                return None

            if attempts > 0:
                # Try to make it a key and see if that fixes the problem
                new_line = (
                    lines[server_position.line].rstrip() + YAML_COMPLETION_HINT_KEY
                )
                lines[server_position.line] = new_line
        except YAMLError:
            break
    return content


def generic_yaml_completer(
    ls: "DebputyLanguageServer",
    params: types.CompletionParams,
    root_parser: DeclarativeInputParser[Any],
) -> types.CompletionList | Sequence[types.CompletionItem] | None:
    doc = ls.workspace.get_text_document(params.text_document.uri)
    lines = _lines(doc.lines)
    server_position = doc.position_codec.position_from_client_units(
        lines, params.position
    )
    orig_line = lines[server_position.line].rstrip()
    has_colon = ":" in orig_line

    content = _insert_complete_marker_and_parse_yaml(lines, server_position)
    if content is None:
        context = lines[server_position.line].replace("\n", "\\n")
        _info(f"Completion failed: parse error: Line in question: {context}")
        return None
    attribute_root_path = AttributePath.root_path(content)
    m = _trace_cursor(content, attribute_root_path, server_position)

    if m is None:
        _info("No match")
        return None
    matched_key, attr_path, matched, parent = m
    _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]")
    feature_set = ls.plugin_feature_set
    segments = list(attr_path.path_segments())
    km = resolve_keyword(
        root_parser,
        DEBPUTY_PLUGIN_METADATA,
        segments,
        0,
        feature_set.manifest_parser_generator,
        is_completion_attempt=True,
    )
    if km is None:
        return None
    parser, _, at_depth_idx = km
    _info(f"Match leaf parser {at_depth_idx} -- {parser.__class__}")
    items = []
    if at_depth_idx + 1 < len(segments):
        return items

    if _is_inside_manifest_variable_substitution(lines, server_position):
        return [
            types.CompletionItem(
                pv.variable_name,
                detail=pv.variable_reference_documentation,
                sort_text=(
                    f"zz-{pv.variable_name}"
                    if pv.is_for_special_case
                    else pv.variable_name
                ),
            )
            for pv in ls.plugin_feature_set.manifest_variables.values()
            if not pv.is_internal
        ]

    if isinstance(parser, DispatchingParserBase):
        if matched_key:
            items = [
                completion_item(
                    (
                        maybe_quote_yaml_value(k)
                        if has_colon
                        else f"{maybe_quote_yaml_value(k)}:"
                    ),
                    pp_subparser,
                )
                for k, pp_subparser in _keywords_with_parser(parser)
                if k not in parent
                and not isinstance(
                    pp_subparser.parser,
                    DeclarativeValuelessKeywordInputParser,
                )
            ]
        else:
            items = [
                completion_item(maybe_quote_yaml_value(k), pp_subparser)
                for k, pp_subparser in _keywords_with_parser(parser)
                if k not in parent
                and isinstance(
                    pp_subparser.parser,
                    DeclarativeValuelessKeywordInputParser,
                )
            ]
    elif isinstance(parser, InPackageContextParser):
        binary_packages = ls.lint_state(doc).binary_packages
        if binary_packages is not None:
            items = [
                types.CompletionItem(
                    maybe_quote_yaml_value(p)
                    if has_colon
                    else f"{maybe_quote_yaml_value(p)}:"
                )
                for p in binary_packages
                if p not in parent
            ]
    elif isinstance(parser, DeclarativeMappingInputParser):
        if matched_key:
            _info("Match attributes")
            locked = set(parent)
            for mx in parser.mutually_exclusive_attributes:
                if not mx.isdisjoint(parent.keys()):
                    locked.update(mx)
            for attr_name, attr in parser.manifest_attributes.items():
                if not attr.conflicting_attributes.isdisjoint(parent.keys()):
                    locked.add(attr_name)
                    break
            items = [
                types.CompletionItem(
                    maybe_quote_yaml_value(k)
                    if has_colon
                    else f"{maybe_quote_yaml_value(k)}:"
                )
                for k in parser.manifest_attributes
                if k not in locked
            ]
        else:
            # Value
            key = segments[at_depth_idx] if len(segments) > at_depth_idx else None
            value_attr = (
                parser.manifest_attributes.get(key) if isinstance(key, str) else None
            )
            if value_attr is not None:
                _info(f"Expand value / key: {key} -- {value_attr.attribute_type}")
                return completion_from_attr(
                    value_attr,
                    feature_set.manifest_parser_generator,
                    matched,
                    matched_key=False,
                    has_colon=has_colon,
                )
            else:
                _info(
                    f"Expand value / key: {key} -- !! {list(parser.manifest_attributes)}"
                )
    elif isinstance(parser, DeclarativeNonMappingInputParser):
        alt_attr = parser.alt_form_parser
        return completion_from_attr(
            alt_attr,
            feature_set.manifest_parser_generator,
            matched,
            matched_key=matched_key,
            has_colon=has_colon,
        )
    return items


def generic_yaml_hover(
    ls: "DebputyLanguageServer",
    params: types.HoverParams,
    root_parser_initializer: Callable[
        [ParserGenerator], DeclarativeInputParser[Any] | DispatchingParserBase
    ],
    *,
    show_integration_mode: bool = False,
) -> types.Hover | None:
    doc = ls.workspace.get_text_document(params.text_document.uri)
    lines = doc.lines
    position_codec = doc.position_codec
    server_position = position_codec.position_from_client_units(lines, params.position)

    try:
        content = MANIFEST_YAML.load(doc.source)
    except YAMLError:
        return None
    attribute_root_path = AttributePath.root_path(content)
    m = _trace_cursor(content, attribute_root_path, server_position)
    if m is None:
        _info("No match")
        return None
    matched_key, attr_path, matched, _ = m
    _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]")

    feature_set = ls.plugin_feature_set
    parser_generator = feature_set.manifest_parser_generator
    root_parser = root_parser_initializer(parser_generator)
    segments = list(attr_path.path_segments())
    km = resolve_keyword(
        root_parser,
        DEBPUTY_PLUGIN_METADATA,
        segments,
        0,
        parser_generator,
    )
    if km is None:
        _info("No keyword match")
        return None
    parser, plugin_metadata, at_depth_idx = km

    manifest_variable_at_pos = _manifest_substitution_variable_at_position(
        lines, server_position
    )

    if manifest_variable_at_pos:
        variable = ls.plugin_feature_set.manifest_variables.get(
            manifest_variable_at_pos
        )
        if variable is not None:
            var_doc = (
                variable.variable_reference_documentation
                or "No documentation available"
            )

            if variable.is_context_specific_variable:
                value = "\nThe value depends on the context"
            else:
                debian_dir = ls.lint_state(doc).debian_dir
                value = ""
                if debian_dir:
                    variable_context = VariableContext(debian_dir)
                    try:
                        resolved = variable.resolve(variable_context)
                        value = f"\nResolves to: `{resolved}`"
                    except RuntimeError:
                        pass

            hover_doc_text = textwrap.dedent("""\
            # `{NAME}`

            {DOC}
            {VALUE}
            """).format(NAME=variable.variable_name, DOC=var_doc, VALUE=value)
            return as_hover_doc(ls, hover_doc_text)

    _info(
        f"Match leaf parser {at_depth_idx}/{len(segments)} -- {parser.__class__} -- {manifest_variable_at_pos}"
    )
    hover_doc_text = resolve_hover_text(
        feature_set,
        parser,
        plugin_metadata,
        ls.hover_output_style,
        show_integration_mode,
        segments,
        at_depth_idx,
        matched,
        matched_key,
    )
    return as_hover_doc(ls, hover_doc_text)
