import dataclasses
import re
import textwrap
from typing import (
    Union,
    Sequence,
    Tuple,
    Optional,
    Mapping,
    List,
    Dict,
    Any,
)

from lsprotocol.types import (
    DiagnosticSeverity,
    Range,
    Diagnostic,
    Position,
    FoldingRange,
    FoldingRangeParams,
    CompletionItem,
    CompletionList,
    CompletionParams,
    DiagnosticRelatedInformation,
    Location,
    HoverParams,
    Hover,
    TEXT_DOCUMENT_CODE_ACTION,
    SemanticTokens,
    SemanticTokensParams,
    WillSaveTextDocumentParams,
    TextEdit,
    DocumentFormattingParams,
)

from debputy.linting.lint_util import LintState
from debputy.lsp.debputy_ls import DebputyLanguageServer
from debputy.lsp.diagnostics import DiagnosticData
from debputy.lsp.lsp_debian_control_reference_data import (
    DctrlKnownField,
    BINARY_FIELDS,
    SOURCE_FIELDS,
    DctrlFileMetadata,
    package_name_to_section,
    all_package_relationship_fields,
    extract_first_value_and_position,
)
from debputy.lsp.lsp_features import (
    lint_diagnostics,
    lsp_completer,
    lsp_hover,
    lsp_standard_handler,
    lsp_folding_ranges,
    lsp_semantic_tokens_full,
    lsp_will_save_wait_until,
    lsp_format_document,
)
from debputy.lsp.lsp_generic_deb822 import (
    deb822_completer,
    deb822_hover,
    deb822_folding_ranges,
    deb822_semantic_tokens_full,
    deb822_token_iter,
    deb822_format_file,
)
from debputy.lsp.quickfixes import (
    propose_remove_line_quick_fix,
    range_compatible_with_remove_line_fix,
    propose_correct_text_quick_fix,
    propose_insert_text_on_line_after_diagnostic_quick_fix,
    propose_remove_range_quick_fix,
)
from debputy.lsp.spellchecking import default_spellchecker
from debputy.lsp.text_util import (
    normalize_dctrl_field_name,
    LintCapablePositionCodec,
    detect_possible_typo,
    te_range_to_lsp,
)
from debputy.lsp.vendoring._deb822_repro import (
    Deb822FileElement,
    Deb822ParagraphElement,
)
from debputy.lsp.vendoring._deb822_repro.parsing import (
    Deb822KeyValuePairElement,
    LIST_SPACE_SEPARATED_INTERPRETATION,
    Interpretation,
    Deb822ParsedTokenList,
)

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

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


_LANGUAGE_IDS = [
    "debian/control",
    # emacs's name
    "debian-control",
    # vim's name
    "debcontrol",
]


@dataclasses.dataclass(slots=True, frozen=True)
class SubstvarMetadata:
    name: str
    defined_by: str
    dh_sequence: Optional[str]
    source: Optional[str]
    description: str

    def render_metadata_fields(self) -> str:
        def_by = f"Defined by: {self.defined_by}"
        dh_seq = (
            f"DH Sequence: {self.dh_sequence}" if self.dh_sequence is not None else None
        )
        source = f"Source: {self.source}" if self.source is not None else None
        return "\n".join(filter(None, (def_by, dh_seq, source)))


def relationship_substvar_for_field(substvar: str) -> Optional[str]:
    relationship_fields = all_package_relationship_fields()
    try:
        col_idx = substvar.rindex(":")
    except ValueError:
        return None
    return relationship_fields.get(substvar[col_idx + 1 : -1].lower())


def _substvars_metadata(*args: SubstvarMetadata) -> Mapping[str, SubstvarMetadata]:
    r = {s.name: s for s in args}
    assert len(r) == len(args)
    return r


_SUBSTVAR_RE = re.compile(r"[$][{][a-zA-Z0-9][a-zA-Z0-9-:]*[}]")
_SUBSTVARS_DOC = _substvars_metadata(
    SubstvarMetadata(
        "${}",
        "`dpkg-gencontrol`",
        "(default)",
        "<https://manpages.debian.org/deb-substvars.5>",
        textwrap.dedent(
            """\
            This is a substvar for a literal `$`. This form will never recurse
            into another substvar. As an example, `${}{binary:Version}` will result
            literal `${binary:Version}` (which will not be replaced).
        """
        ),
    ),
    SubstvarMetadata(
        "${binary:Version}",
        "`dpkg-gencontrol`",
        "(default)",
        "<https://manpages.debian.org/deb-substvars.5>",
        textwrap.dedent(
            """\
            The version of the current binary package including binNMU version.

            Often used with `Depends: dep (= ${binary:Version})` relations
            where:

             * The `dep` package is from the same source (listed in the same
               `debian/control` file)
             * The current package and `dep` are both `arch:any` (or both `arch:all`)
               packages.
    """
        ),
    ),
    SubstvarMetadata(
        "${source:Version}",
        "`dpkg-gencontrol`",
        "(default)",
        "<https://manpages.debian.org/deb-substvars.5>",
        textwrap.dedent(
            """\
            The version of the current source package excluding binNMU version.

            Often used with `Depends: dep (= ${source:Version})` relations
            where:

             * The `dep` package is from the same source (listed in the same
               `debian/control` file)
             * The `dep` is `arch:all`.
    """
        ),
    ),
    SubstvarMetadata(
        "${misc:Depends}",
        "`debhelper`",
        "(default)",
        "<https://manpages.debian.org/debhelper.7>",
        textwrap.dedent(
            """\
            Some debhelper commands may make the generated package need to depend on some other packages.
            For example, if you use `dh_installdebconf(1)`, your package will generally need to depend on
            debconf. Or if you use `dh_installxfonts(1)`, your package will generally need to depend on a
            particular version of xutils. Keeping track of these miscellaneous dependencies can be
            annoying since they are dependent on how debhelper does things, so debhelper offers a way to
            automate it.

            All commands of this type, besides documenting what dependencies may be needed on their man
            pages, will automatically generate a substvar called ${misc:Depends}. If you put that token
            into your `debian/control` file, it will be expanded to the dependencies debhelper figures
            you need.

            This is entirely independent of the standard `${shlibs:Depends}` generated by `dh_makeshlibs(1)`,
            and the `${perl:Depends}` generated by `dh_perl(1)`.
    """
        ),
    ),
    SubstvarMetadata(
        "${misc:Pre-Depends}",
        "`debhelper`",
        "(default)",
        None,
        textwrap.dedent(
            """\
            This is the moral equivalent to `${misc:Depends}` but for `Pre-Depends`.
    """
        ),
    ),
    SubstvarMetadata(
        "${perl:Depends}",
        "`dh_perl`",
        "(default)",
        "<https://manpages.debian.org/dh_perl.1>",
        textwrap.dedent(
            """\
            The dependency on perl as determined by `dh_perl`. Note this only covers the relationship
            with the Perl interpreter and not perl modules.

    """
        ),
    ),
    SubstvarMetadata(
        "${gir:Depends}",
        "`dh_girepository`",
        "gir",
        "<https://manpages.debian.org/dh_girepository.1>",
        textwrap.dedent(
            """\
            Dependencies related to GObject introspection data.
    """
        ),
    ),
    SubstvarMetadata(
        "${shlibs:Depends}",
        "`dpkg-shlibdeps` (often via `dh_shlibdeps`)",
        "(default)",
        "<https://manpages.debian.org/dpkg-shlibdeps.1>",
        textwrap.dedent(
            """\
            Dependencies related to ELF dependencies.
    """
        ),
    ),
    SubstvarMetadata(
        "${shlibs:Pre-Depends}",
        "`dpkg-shlibdeps` (often via `dh_shlibdeps`)",
        "(default)",
        "<https://manpages.debian.org/dpkg-shlibdeps.1>",
        textwrap.dedent(
            """\
            Dependencies related to ELF dependencies. The `Pre-Depends`
            version is often only seen in `Essential: yes` packages
            or packages that manually request the `Pre-Depends`
            relation via `dpkg-shlibdeps`.

            Note: This substvar only appears in `debhelper-compat (= 14)`, or
            with use of `debputy` (at an integration level, where `debputy`
            runs `dpkg-shlibdeps`), or when passing relevant options to
            `dpkg-shlibdeps`  (often via `dh_shlibdeps`) such as `-dPre-Depends`.
    """
        ),
    ),
)

_DCTRL_FILE_METADATA = DctrlFileMetadata()


lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_CODE_ACTION)


@lsp_hover(_LANGUAGE_IDS)
def _debian_control_hover(
    ls: "DebputyLanguageServer",
    params: HoverParams,
) -> Optional[Hover]:
    return deb822_hover(ls, params, _DCTRL_FILE_METADATA, custom_handler=_custom_hover)


def _custom_hover(
    server_position: Position,
    _current_field: Optional[str],
    _word_at_position: str,
    known_field: Optional[DctrlKnownField],
    in_value: bool,
    _doc: "TextDocument",
    lines: List[str],
) -> Optional[Union[Hover, str]]:
    if not in_value:
        return None

    line_no = server_position.line
    line = lines[line_no]
    substvar_search_ref = server_position.character
    substvar = ""
    try:
        if line and line[substvar_search_ref] in ("$", "{"):
            substvar_search_ref += 2
        substvar_start = line.rindex("${", 0, substvar_search_ref)
        substvar_end = line.index("}", substvar_start)
        if server_position.character <= substvar_end:
            substvar = line[substvar_start : substvar_end + 1]
    except (ValueError, IndexError):
        pass

    if substvar == "${}" or _SUBSTVAR_RE.fullmatch(substvar):
        substvar_md = _SUBSTVARS_DOC.get(substvar)

        computed_doc = ""
        for_field = relationship_substvar_for_field(substvar)
        if for_field:
            # Leading empty line is intentional!
            computed_doc = textwrap.dedent(
                f"""
                This substvar is a relationship substvar for the field {for_field}.
                Relationship substvars are automatically added in the field they
                are named after in `debhelper-compat (= 14)` or later, or with
                `debputy` (any integration mode after 0.1.21).
            """
            )

        if substvar_md is None:
            doc = f"No documentation for {substvar}.\n"
            md_fields = ""
        else:
            doc = substvar_md.description
            md_fields = "\n" + substvar_md.render_metadata_fields()
        return f"# Substvar `{substvar}`\n\n{doc}{computed_doc}{md_fields}"

    if known_field is None or known_field.name != "Description":
        return None
    if line[0].isspace():
        return None
    try:
        col_idx = line.index(":")
    except ValueError:
        return None

    content = line[col_idx + 1 :].strip()
    # Synopsis
    return textwrap.dedent(
        f"""\
        # Package synopsis

        The synopsis is a single line "noun phrase" description of the package.
        It is typically used in search results and other cases where a
        user-interface has limited space for text.

        **Example renderings in various terminal UIs**:
        ```
        # apt search TERM
        package/stable,now 1.0-1 all:
           {content}

        # apt-get search TERM
        package - {content}
        ```

        Advice for writing synopsis:
         * Avoid using the package name. Any software would display the
           package name already and it generally does not help the user
           understand what they are looking at.
         * In many situations, the user will only see the package name
           and its synopsis. The synopsis must standalone.
         * When writing the synopsis, it is often a good idea to write
           it such that it fits into the sentence like "This package
           provides [a|an|the] ..." (see below).

        **Phrasing test**:
        ```
        This package provides [a|an|the] {content}.
        ```
    """
    )


@lsp_completer(_LANGUAGE_IDS)
def _debian_control_completions(
    ls: "DebputyLanguageServer",
    params: CompletionParams,
) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
    return deb822_completer(ls, params, _DCTRL_FILE_METADATA)


@lsp_folding_ranges(_LANGUAGE_IDS)
def _debian_control_folding_ranges(
    ls: "DebputyLanguageServer",
    params: FoldingRangeParams,
) -> Optional[Sequence[FoldingRange]]:
    return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA)


def _paragraph_representation_field(
    paragraph: Deb822ParagraphElement,
) -> Deb822KeyValuePairElement:
    return next(iter(paragraph.iter_parts_of_type(Deb822KeyValuePairElement)))


def _source_package_checks(
    stanza: Deb822ParagraphElement,
    stanza_position: "TEPosition",
    lint_state: LintState,
    diagnostics: List[Diagnostic],
) -> None:
    vcs_fields = {}
    for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement):
        name = normalize_dctrl_field_name(kvpair.field_name.lower())
        if (
            not name.startswith("vcs-")
            or name == "vcs-browser"
            or name not in SOURCE_FIELDS
        ):
            continue
        vcs_fields[name] = kvpair

    if len(vcs_fields) < 2:
        return
    for kvpair in vcs_fields.values():
        kvpair_range_server_units = te_range_to_lsp(
            kvpair.range_in_parent().relative_to(stanza_position)
        )
        diagnostics.append(
            Diagnostic(
                lint_state.position_codec.range_to_client_units(
                    lint_state.lines, kvpair_range_server_units
                ),
                f'Multiple Version Control fields defined ("{kvpair.field_name}")',
                severity=DiagnosticSeverity.Warning,
                source="debputy",
                data=DiagnosticData(
                    quickfixes=[
                        propose_remove_range_quick_fix(
                            proposed_title=f'Remove "{kvpair.field_name}"'
                        )
                    ]
                ),
            )
        )


def _binary_package_checks(
    stanza: Deb822ParagraphElement,
    stanza_position: "TEPosition",
    source_stanza: Deb822ParagraphElement,
    representation_field_range: Range,
    lint_state: LintState,
    diagnostics: List[Diagnostic],
) -> None:
    package_name = stanza.get("Package", "")
    source_section = source_stanza.get("Section")
    section_kvpair = stanza.get_kvpair_element("Section", use_get=True)
    section: Optional[str] = None
    if section_kvpair is not None:
        section, section_range = extract_first_value_and_position(
            section_kvpair,
            stanza_position,
            lint_state,
        )
    else:
        section_range = representation_field_range
    effective_section = section or source_section or "unknown"
    package_type = stanza.get("Package-Type", "")
    component_prefix = ""
    if "/" in effective_section:
        component_prefix, effective_section = effective_section.split("/", maxsplit=1)
        component_prefix += "/"

    if package_name.endswith("-udeb") or package_type == "udeb":
        if package_type != "udeb":
            package_type_kvpair = stanza.get_kvpair_element(
                "Package-Type", use_get=True
            )
            package_type_range = None
            if package_type_kvpair is not None:
                _, package_type_range = extract_first_value_and_position(
                    package_type_kvpair,
                    stanza_position,
                    lint_state,
                )
            if package_type_range is None:
                package_type_range = representation_field_range
            diagnostics.append(
                Diagnostic(
                    package_type_range,
                    'The Package-Type should be "udeb" given the package name',
                    severity=DiagnosticSeverity.Warning,
                    source="debputy",
                )
            )
        guessed_section = "debian-installer"
        section_diagnostic_rationale = " since it is an udeb"
    else:
        guessed_section = package_name_to_section(package_name)
        section_diagnostic_rationale = " based on the package name"
    if guessed_section is not None and guessed_section != effective_section:
        if section is not None:
            quickfix_data = [
                propose_correct_text_quick_fix(f"{component_prefix}{guessed_section}")
            ]
        else:
            quickfix_data = [
                propose_insert_text_on_line_after_diagnostic_quick_fix(
                    f"Section: {component_prefix}{guessed_section}\n"
                )
            ]
        assert section_range is not None  # mypy hint
        diagnostics.append(
            Diagnostic(
                section_range,
                f'The Section should be "{component_prefix}{guessed_section}"{section_diagnostic_rationale}',
                severity=DiagnosticSeverity.Warning,
                source="debputy",
                data=DiagnosticData(quickfixes=quickfix_data),
            )
        )


def _diagnostics_for_paragraph(
    stanza: Deb822ParagraphElement,
    stanza_position: "TEPosition",
    source_stanza: Deb822ParagraphElement,
    known_fields: Mapping[str, DctrlKnownField],
    other_known_fields: Mapping[str, DctrlKnownField],
    is_binary_paragraph: bool,
    doc_reference: str,
    lint_state: LintState,
    diagnostics: List[Diagnostic],
) -> None:
    representation_field = _paragraph_representation_field(stanza)
    representation_field_range = representation_field.range_in_parent().relative_to(
        stanza_position
    )
    representation_field_range = lint_state.position_codec.range_to_client_units(
        lint_state.lines,
        te_range_to_lsp(representation_field_range),
    )
    for known_field in known_fields.values():
        missing_field_severity = known_field.missing_field_severity
        if missing_field_severity is None or known_field.name in stanza:
            continue

        if known_field.inherits_from_source and known_field.name in source_stanza:
            continue

        diagnostics.append(
            Diagnostic(
                representation_field_range,
                f"Stanza is missing field {known_field.name}",
                severity=missing_field_severity,
                source="debputy",
            )
        )

    if is_binary_paragraph:
        _binary_package_checks(
            stanza,
            stanza_position,
            source_stanza,
            representation_field_range,
            lint_state,
            diagnostics,
        )
    else:
        _source_package_checks(
            stanza,
            stanza_position,
            lint_state,
            diagnostics,
        )

    seen_fields: Dict[str, Tuple[str, str, Range, List[Range]]] = {}

    for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement):
        field_name_token = kvpair.field_token
        field_name = field_name_token.text
        field_name_lc = field_name.lower()
        normalized_field_name_lc = normalize_dctrl_field_name(field_name_lc)
        known_field = known_fields.get(normalized_field_name_lc)
        field_value = stanza[field_name]
        kvpair_position = kvpair.position_in_parent().relative_to(stanza_position)
        field_range_te = kvpair.field_token.range_in_parent().relative_to(
            kvpair_position
        )
        field_position_te = field_range_te.start_pos
        field_range_server_units = te_range_to_lsp(field_range_te)
        field_range = lint_state.position_codec.range_to_client_units(
            lint_state.lines,
            field_range_server_units,
        )
        field_name_typo_detected = False
        existing_field_range = seen_fields.get(normalized_field_name_lc)
        if existing_field_range is not None:
            existing_field_range[3].append(field_range)
        else:
            normalized_field_name = normalize_dctrl_field_name(field_name)
            seen_fields[field_name_lc] = (
                field_name,
                normalized_field_name,
                field_range,
                [],
            )

        if known_field is None:
            candidates = detect_possible_typo(normalized_field_name_lc, known_fields)
            if candidates:
                known_field = known_fields[candidates[0]]
                token_range_server_units = te_range_to_lsp(
                    TERange.from_position_and_size(
                        field_position_te, kvpair.field_token.size()
                    )
                )
                field_range = lint_state.position_codec.range_to_client_units(
                    lint_state.lines,
                    token_range_server_units,
                )
                field_name_typo_detected = True
                diagnostics.append(
                    Diagnostic(
                        field_range,
                        f'The "{field_name}" looks like a typo of "{known_field.name}".',
                        severity=DiagnosticSeverity.Warning,
                        source="debputy",
                        data=DiagnosticData(
                            quickfixes=[
                                propose_correct_text_quick_fix(known_fields[m].name)
                                for m in candidates
                            ]
                        ),
                    )
                )
        if known_field is None:
            known_else_where = other_known_fields.get(normalized_field_name_lc)
            if known_else_where is not None:
                intended_usage = "Source" if is_binary_paragraph else "Package"
                diagnostics.append(
                    Diagnostic(
                        field_range,
                        f'The {field_name} is defined for use in the "{intended_usage}" stanza.'
                        f" Please move it to the right place or remove it",
                        severity=DiagnosticSeverity.Error,
                        source="debputy",
                    )
                )
            continue

        if field_value.strip() == "":
            diagnostics.append(
                Diagnostic(
                    field_range,
                    f"The {field_name} has no value. Either provide a value or remove it.",
                    severity=DiagnosticSeverity.Error,
                    source="debputy",
                )
            )
            continue
        diagnostics.extend(
            known_field.field_diagnostics(
                kvpair,
                stanza,
                stanza_position,
                kvpair_position,
                lint_state,
                field_name_typo_reported=field_name_typo_detected,
            )
        )
        if known_field.spellcheck_value:
            words = kvpair.interpret_as(LIST_SPACE_SEPARATED_INTERPRETATION)
            spell_checker = default_spellchecker()
            value_position = kvpair.value_element.position_in_parent().relative_to(
                field_position_te
            )
            for word_ref in words.iter_value_references():
                token = word_ref.value
                for word, pos, endpos in spell_checker.iter_words(token):
                    corrections = spell_checker.provide_corrections_for(word)
                    if not corrections:
                        continue
                    word_loc = word_ref.locatable
                    word_pos_te = word_loc.position_in_parent().relative_to(
                        value_position
                    )
                    if pos:
                        word_pos_te = TEPosition(0, pos).relative_to(word_pos_te)
                    word_range_te = TERange(
                        START_POSITION,
                        TEPosition(0, endpos - pos),
                    )
                    word_range_server_units = te_range_to_lsp(
                        TERange.from_position_and_size(word_pos_te, word_range_te)
                    )
                    word_range = lint_state.position_codec.range_to_client_units(
                        lint_state.lines,
                        word_range_server_units,
                    )
                    diagnostics.append(
                        Diagnostic(
                            word_range,
                            f'Spelling "{word}"',
                            severity=DiagnosticSeverity.Hint,
                            source="debputy",
                            data=DiagnosticData(
                                lint_severity="spelling",
                                quickfixes=[
                                    propose_correct_text_quick_fix(c)
                                    for c in corrections
                                ],
                            ),
                        )
                    )
        source_value = source_stanza.get(field_name)
        if known_field.warn_if_default and field_value == known_field.default_value:
            diagnostics.append(
                Diagnostic(
                    field_range,
                    f"The {field_name} is redundant as it is set to the default value and the field should only be"
                    " used in exceptional cases.",
                    severity=DiagnosticSeverity.Warning,
                    source="debputy",
                )
            )

        if known_field.inherits_from_source and field_value == source_value:
            if range_compatible_with_remove_line_fix(field_range):
                fix_data = propose_remove_line_quick_fix()
            else:
                fix_data = None
            diagnostics.append(
                Diagnostic(
                    field_range,
                    f"The field {field_name} duplicates the value from the Source stanza.",
                    severity=DiagnosticSeverity.Information,
                    source="debputy",
                    data=DiagnosticData(quickfixes=fix_data),
                )
            )
    for (
        field_name,
        normalized_field_name,
        field_range,
        duplicates,
    ) in seen_fields.values():
        if not duplicates:
            continue
        related_information = [
            DiagnosticRelatedInformation(
                location=Location(doc_reference, field_range),
                message=f"First definition of {field_name}",
            )
        ]
        related_information.extend(
            DiagnosticRelatedInformation(
                location=Location(doc_reference, r),
                message=f"Duplicate of {field_name}",
            )
            for r in duplicates
        )
        for dup_range in duplicates:
            diagnostics.append(
                Diagnostic(
                    dup_range,
                    f"The {normalized_field_name} field name was used multiple times in this stanza."
                    f" Please ensure the field is only used once per stanza. Note that {normalized_field_name} and"
                    f" X[BCS]-{normalized_field_name} are considered the same field.",
                    severity=DiagnosticSeverity.Error,
                    source="debputy",
                    related_information=related_information,
                )
            )


def _scan_for_syntax_errors_and_token_level_diagnostics(
    deb822_file: Deb822FileElement,
    position_codec: LintCapablePositionCodec,
    lines: List[str],
    diagnostics: List[Diagnostic],
) -> int:
    first_error = len(lines) + 1
    spell_checker = default_spellchecker()
    for (
        token,
        start_line,
        start_offset,
        end_line,
        end_offset,
    ) in deb822_token_iter(deb822_file.iter_tokens()):
        if token.is_error:
            first_error = min(first_error, start_line)
            start_pos = Position(
                start_line,
                start_offset,
            )
            end_pos = Position(
                end_line,
                end_offset,
            )
            token_range = position_codec.range_to_client_units(
                lines, Range(start_pos, end_pos)
            )
            diagnostics.append(
                Diagnostic(
                    token_range,
                    "Syntax error",
                    severity=DiagnosticSeverity.Error,
                    source="debputy (python-debian parser)",
                )
            )
        elif token.is_comment:
            for word, col_pos, end_col_pos in spell_checker.iter_words(token.text):
                corrections = spell_checker.provide_corrections_for(word)
                if not corrections:
                    continue
                start_pos = Position(
                    start_line,
                    col_pos,
                )
                end_pos = Position(
                    start_line,
                    end_col_pos,
                )
                word_range = position_codec.range_to_client_units(
                    lines, Range(start_pos, end_pos)
                )
                diagnostics.append(
                    Diagnostic(
                        word_range,
                        f'Spelling "{word}"',
                        severity=DiagnosticSeverity.Hint,
                        source="debputy",
                        data=DiagnosticData(
                            lint_severity="spelling",
                            quickfixes=[
                                propose_correct_text_quick_fix(c) for c in corrections
                            ],
                        ),
                    )
                )
    return first_error


@lint_diagnostics(_LANGUAGE_IDS)
def _lint_debian_control(
    lint_state: LintState,
) -> Optional[List[Diagnostic]]:
    lines = lint_state.lines
    position_codec = lint_state.position_codec
    doc_reference = lint_state.doc_uri
    diagnostics = []
    deb822_file = lint_state.parsed_deb822_file_content

    first_error = _scan_for_syntax_errors_and_token_level_diagnostics(
        deb822_file,
        position_codec,
        lines,
        diagnostics,
    )

    paragraphs = list(deb822_file)
    source_paragraph = paragraphs[0] if paragraphs else None

    for paragraph_no, paragraph in enumerate(paragraphs, start=1):
        paragraph_pos = paragraph.position_in_file()
        if paragraph_pos.line_position >= first_error:
            break
        is_binary_paragraph = paragraph_no != 1
        if is_binary_paragraph:
            known_fields = BINARY_FIELDS
            other_known_fields = SOURCE_FIELDS
        else:
            known_fields = SOURCE_FIELDS
            other_known_fields = BINARY_FIELDS
        _diagnostics_for_paragraph(
            paragraph,
            paragraph_pos,
            source_paragraph,
            known_fields,
            other_known_fields,
            is_binary_paragraph,
            doc_reference,
            lint_state,
            diagnostics,
        )

    return diagnostics


@lsp_will_save_wait_until(_LANGUAGE_IDS)
def _debian_control_on_save_formatting(
    ls: "DebputyLanguageServer",
    params: WillSaveTextDocumentParams,
) -> Optional[Sequence[TextEdit]]:
    doc = ls.workspace.get_text_document(params.text_document.uri)
    lint_state = ls.lint_state(doc)
    return _reformat_debian_control(lint_state)


def _reformat_debian_control(
    lint_state: LintState,
) -> Optional[Sequence[TextEdit]]:
    return deb822_format_file(lint_state, _DCTRL_FILE_METADATA)


@lsp_format_document(_LANGUAGE_IDS)
def _debian_control_on_save_formatting(
    ls: "DebputyLanguageServer",
    params: DocumentFormattingParams,
) -> Optional[Sequence[TextEdit]]:
    doc = ls.workspace.get_text_document(params.text_document.uri)
    lint_state = ls.lint_state(doc)
    return deb822_format_file(lint_state, _DCTRL_FILE_METADATA)


@lsp_semantic_tokens_full(_LANGUAGE_IDS)
def _debian_control_semantic_tokens_full(
    ls: "DebputyLanguageServer",
    request: SemanticTokensParams,
) -> Optional[SemanticTokens]:
    return deb822_semantic_tokens_full(
        ls,
        request,
        _DCTRL_FILE_METADATA,
    )
