import collections
import dataclasses
import functools
import importlib.resources
import itertools
import operator
import os.path
import re
import textwrap
from abc import ABC
from typing import (
    FrozenSet,
    Optional,
    cast,
    List,
    Generic,
    TypeVar,
    Union,
    Tuple,
    Any,
    Set,
    TYPE_CHECKING,
    Dict,
    Self,
)
from collections.abc import Mapping, Iterable, Callable, Sequence, Iterator, Container

from debian.debian_support import DpkgArchTable, Version

import debputy.lsp.data.deb822_data as deb822_ref_data_dir
from debputy.filesystem_scan import VirtualPathBase
from debputy.linting.lint_util import LintState, with_range_in_continuous_parts
from debputy.linting.lint_util import te_range_to_lsp
from debputy.lsp.diagnostics import LintSeverity
from debputy.lsp.lsp_reference_keyword import (
    Keyword,
    allowed_values,
    format_comp_item_synopsis_doc,
    LSP_DATA_DOMAIN,
    ALL_PUBLIC_NAMED_STYLES_AS_KEYWORDS,
)
from debputy.lsp.quickfixes import (
    propose_correct_text_quick_fix,
    propose_remove_range_quick_fix,
)
from debputy.lsp.ref_models.deb822_reference_parse_models import (
    Deb822ReferenceData,
    DEB822_REFERENCE_DATA_PARSER,
    FieldValueClass,
    StaticValue,
    Deb822Field,
    UsageHint,
    Alias,
)
from debputy.lsp.text_edit import apply_text_edits
from debputy.lsp.text_util import (
    normalize_dctrl_field_name,
    LintCapablePositionCodec,
    trim_end_of_line_whitespace,
)
from debian._deb822_repro.parsing import (
    Deb822KeyValuePairElement,
    LIST_SPACE_SEPARATED_INTERPRETATION,
    Deb822ParagraphElement,
    Deb822FileElement,
    Interpretation,
    parse_deb822_file,
    Deb822ParsedTokenList,
    Deb822ValueLineElement,
)
from debian._deb822_repro.tokens import (
    Deb822FieldNameToken,
)
from debian._deb822_repro.types import FormatterCallback, TE
from debputy.lsp.vendoring.wrap_and_sort import _sort_packages_key
from debputy.lsprotocol.types import (
    DiagnosticTag,
    Range,
    TextEdit,
    Position,
    CompletionItem,
    MarkupContent,
    CompletionItemTag,
    MarkupKind,
    CompletionItemKind,
    CompletionItemLabelDetails,
)
from debputy.manifest_parser.exceptions import ManifestParseException
from debputy.manifest_parser.util import AttributePath
from debputy.path_matcher import BasenameGlobMatch
from debputy.plugin.api import VirtualPath
from debputy.util import PKGNAME_REGEX, _info, detect_possible_typo, _error
from debputy.yaml import MANIFEST_YAML

try:
    from debian._deb822_repro.locatable import (
        Position as TEPosition,
        Range as TERange,
        START_POSITION,
    )
except ImportError:
    pass


if TYPE_CHECKING:
    from debputy.lsp.maint_prefs import EffectiveFormattingPreference
    from debputy.lsp.debputy_ls import DebputyLanguageServer


F = TypeVar("F", bound="Deb822KnownField", covariant=True)
S = TypeVar("S", bound="StanzaMetadata")


SUBSTVAR_RE = re.compile(r"[$][{][a-zA-Z0-9][a-zA-Z0-9-:]*[}]")

_RE_SYNOPSIS_STARTS_WITH_ARTICLE = re.compile(r"^\s*(an?|the)(?:\s|$)", re.I)
_RE_SV = re.compile(r"(\d+[.]\d+[.]\d+)([.]\d+)?")
_RE_SYNOPSIS_IS_TEMPLATE = re.compile(
    r"^\s*(missing|<insert up to \d+ chars description>)$"
)
_RE_SYNOPSIS_IS_TOO_SHORT = re.compile(r"^\s*(\S+)$")
CURRENT_STANDARDS_VERSION = Version("4.7.4")


CustomFieldCheck = Callable[
    [
        "F",
        Deb822FileElement,
        Deb822KeyValuePairElement,
        "TERange",
        "TERange",
        Deb822ParagraphElement,
        "TEPosition",
        LintState,
    ],
    None,
]


@functools.lru_cache
def all_package_relationship_fields() -> Mapping[str, str]:
    # TODO: Pull from `dpkg-dev` when possible fallback only to the static list.
    return {
        f.lower(): f
        for f in (
            "Pre-Depends",
            "Depends",
            "Recommends",
            "Suggests",
            "Enhances",
            "Conflicts",
            "Breaks",
            "Replaces",
            "Provides",
            "Built-Using",
            "Static-Built-Using",
        )
    }


@functools.lru_cache
def all_source_relationship_fields() -> Mapping[str, str]:
    # TODO: Pull from `dpkg-dev` when possible fallback only to the static list.
    return {
        f.lower(): f
        for f in (
            "Build-Depends",
            "Build-Depends-Arch",
            "Build-Depends-Indep",
            "Build-Conflicts",
            "Build-Conflicts-Arch",
            "Build-Conflicts-Indep",
        )
    }


ALL_SECTIONS_WITHOUT_COMPONENT = frozenset(
    [
        "admin",
        "cli-mono",
        "comm",
        "database",
        "debian-installer",
        "debug",
        "devel",
        "doc",
        "editors",
        "education",
        "electronics",
        "embedded",
        "fonts",
        "games",
        "gnome",
        "gnu-r",
        "gnustep",
        "golang",
        "graphics",
        "hamradio",
        "haskell",
        "httpd",
        "interpreters",
        "introspection",
        "java",
        "javascript",
        "kde",
        "kernel",
        "libdevel",
        "libs",
        "lisp",
        "localization",
        "mail",
        "math",
        "metapackages",
        "misc",
        "net",
        "news",
        "ocaml",
        "oldlibs",
        "otherosfs",
        "perl",
        "php",
        "python",
        "ruby",
        "rust",
        "science",
        "shells",
        "sound",
        "tasks",
        "tex",
        "text",
        "utils",
        "vcs",
        "video",
        "virtual",
        "web",
        "x11",
        "xfce",
        "zope",
    ]
)

ALL_COMPONENTS = frozenset(
    [
        "main",
        "restricted",  # Ubuntu
        "non-free",
        "non-free-firmware",
        "contrib",
    ]
)


def _fields(*fields: F) -> Mapping[str, F]:
    return {normalize_dctrl_field_name(f.name.lower()): f for f in fields}


def _complete_section_sort_hint(
    keyword: Keyword,
    _lint_state: LintState,
    stanza_parts: Sequence[Deb822ParagraphElement],
    _value_being_completed: str,
) -> str | None:
    for stanza in stanza_parts:
        pkg = stanza.get("Package")
        if pkg is not None:
            break
    else:
        return None
    section = package_name_to_section(pkg)
    value_parts = keyword.value.rsplit("/", 1)
    keyword_section = value_parts[-1]
    keyword_component = f" ({value_parts[0]})" if len(value_parts) > 1 else ""
    if section is None:
        if keyword_component == "":
            return keyword_section
        return f"zz-{keyword_section}{keyword_component}"
    if keyword_section != section:
        return f"zz-{keyword_section}{keyword_component}"
    return f"aa-{keyword_section}{keyword_component}"


ALL_SECTIONS = allowed_values(
    Keyword(
        s if c is None else f"{c}/{s}",
        sort_text=_complete_section_sort_hint,
        replaced_by=s if c == "main" else None,
    )
    for c, s in itertools.product(
        itertools.chain(cast("Iterable[Optional[str]]", [None]), ALL_COMPONENTS),
        ALL_SECTIONS_WITHOUT_COMPONENT,
    )
)


def all_architectures_and_wildcards(
    arch2table, *, allow_negations: bool = False
) -> Iterable[str | Keyword]:
    wildcards = set()
    yield Keyword(
        "any",
        is_exclusive=True,
        synopsis="Built once per machine architecture (native code, such as C/C++, interpreter to C bindings)",
        long_description=textwrap.dedent("""\
            This is an architecture-dependent package, and needs to be
            compiled for each and every architecture.

            The name `any` refers to the fact that this is an architecture
            *wildcard* matching *any machine architecture* supported by
            dpkg.
        """),
    )
    yield Keyword(
        "all",
        is_exclusive=True,
        synopsis="Independent of machine architecture (scripts, data, documentation, or Java without JNI)",
        long_description=textwrap.dedent("""\
            The package is an architecture independent package.  This is
            typically appropriate for packages containing only scripts,
            data or documentation.

            The name `all` refers to the fact that the same build of a package
            can be used for *all* architectures. Though note that it is still
            subject to the rules of the `Multi-Arch` field.
        """),
    )
    for arch_name, quad_tuple in arch2table.items():
        yield arch_name
        if allow_negations:
            yield f"!{arch_name}"
        cpu_wc = "any-" + quad_tuple.cpu_name
        os_wc = quad_tuple.os_name + "-any"
        if cpu_wc not in wildcards:
            yield cpu_wc
            if allow_negations:
                yield f"!{cpu_wc}"
            wildcards.add(cpu_wc)
        if os_wc not in wildcards:
            yield os_wc
            if allow_negations:
                yield f"!{os_wc}"
            wildcards.add(os_wc)
        # Add the remaining wildcards


@functools.lru_cache
def dpkg_arch_and_wildcards(*, allow_negations=False) -> frozenset[str | Keyword]:
    dpkg_arch_table = DpkgArchTable.load_arch_table()
    return frozenset(
        all_architectures_and_wildcards(
            dpkg_arch_table._arch2table,
            allow_negations=allow_negations,
        )
    )


def extract_first_value_and_position(
    kvpair: Deb822KeyValuePairElement,
    stanza_pos: "TEPosition",
    *,
    interpretation: Interpretation[
        Deb822ParsedTokenList[Any, Any]
    ] = LIST_SPACE_SEPARATED_INTERPRETATION,
) -> tuple[str | None, TERange | None]:
    kvpair_pos = kvpair.position_in_parent().relative_to(stanza_pos)
    value_element_pos = kvpair.value_element.position_in_parent().relative_to(
        kvpair_pos
    )
    for value_ref in kvpair.interpret_as(interpretation).iter_value_references():
        v = value_ref.value
        section_value_loc = value_ref.locatable
        value_range_te = section_value_loc.range_in_parent().relative_to(
            value_element_pos
        )
        return v, value_range_te
    return None, None


def _sv_field_validation(
    known_field: "F",
    _deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    _kvpair_range: "TERange",
    _field_name_range_te: "TERange",
    _stanza: Deb822ParagraphElement,
    stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    sv_value, sv_value_range = extract_first_value_and_position(
        kvpair,
        stanza_position,
    )
    m = _RE_SV.fullmatch(sv_value)
    if m is None:
        lint_state.emit_diagnostic(
            sv_value_range,
            f'Not a valid standards version. Current version is "{CURRENT_STANDARDS_VERSION}"',
            "warning",
            known_field.unknown_value_authority,
        )
        return

    sv_version = Version(sv_value)
    if sv_version < CURRENT_STANDARDS_VERSION:
        lint_state.emit_diagnostic(
            sv_value_range,
            f"Latest Standards-Version is {CURRENT_STANDARDS_VERSION}",
            "informational",
            known_field.unknown_value_authority,
        )
        return
    extra = m.group(2)
    if extra:
        extra_len = lint_state.position_codec.client_num_units(extra)
        lint_state.emit_diagnostic(
            TERange.between(
                TEPosition(
                    sv_value_range.end_pos.line_position,
                    sv_value_range.end_pos.cursor_position - extra_len,
                ),
                sv_value_range.end_pos,
            ),
            "Unnecessary version segment. This part of the version is only used for editorial changes",
            "informational",
            known_field.unknown_value_authority,
            quickfixes=[
                propose_remove_range_quick_fix(
                    proposed_title="Remove unnecessary version part"
                )
            ],
        )


def _dctrl_ma_field_validation(
    _known_field: "F",
    _deb822_file: Deb822FileElement,
    _kvpair: Deb822KeyValuePairElement,
    _kvpair_range: "TERange",
    _field_name_range: "TERange",
    stanza: Deb822ParagraphElement,
    stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    ma_kvpair = stanza.get_kvpair_element(("Multi-Arch", 0), use_get=True)
    arch = stanza.get("Architecture", "any")
    if arch == "all" and ma_kvpair is not None:
        ma_value, ma_value_range = extract_first_value_and_position(
            ma_kvpair,
            stanza_position,
        )
        if ma_value == "same":
            lint_state.emit_diagnostic(
                ma_value_range,
                "Multi-Arch: same is not valid for Architecture: all packages. Maybe you want foreign?",
                "error",
                "debputy",
            )


def _udeb_only_field_validation(
    known_field: "F",
    _deb822_file: Deb822FileElement,
    _kvpair: Deb822KeyValuePairElement,
    _kvpair_range: "TERange",
    field_name_range: "TERange",
    stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    package_type = stanza.get("Package-Type")
    if package_type != "udeb":
        lint_state.emit_diagnostic(
            field_name_range,
            f"The {known_field.name} field is only applicable to udeb packages (`Package-Type: udeb`)",
            "warning",
            "debputy",
        )


def _complete_only_in_arch_dep_pkgs(
    stanza_parts: Iterable[Deb822ParagraphElement],
) -> bool:
    for stanza in stanza_parts:
        arch = stanza.get("Architecture")
        if arch is None:
            continue
        archs = arch.split()
        return "all" not in archs
    return False


def _complete_only_for_udeb_pkgs(
    stanza_parts: Iterable[Deb822ParagraphElement],
) -> bool:
    for stanza in stanza_parts:
        for option in ("Package-Type", "XC-Package-Type"):
            pkg_type = stanza.get(option)
            if pkg_type is not None:
                return pkg_type == "udeb"
    return False


def _arch_not_all_only_field_validation(
    known_field: "F",
    _deb822_file: Deb822FileElement,
    _kvpair: Deb822KeyValuePairElement,
    _kvpair_range_te: "TERange",
    field_name_range_te: "TERange",
    stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    architecture = stanza.get("Architecture")
    if architecture == "all":
        lint_state.emit_diagnostic(
            field_name_range_te,
            f"The {known_field.name} field is not applicable to arch:all packages (`Architecture: all`)",
            "warning",
            "debputy",
        )


def _binary_package_from_same_source(
    known_field: "F",
    _deb822_file: Deb822FileElement,
    _kvpair: Deb822KeyValuePairElement,
    kvpair_range: "TERange",
    _field_name_range: "TERange",
    stanza: Deb822ParagraphElement,
    stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    doc_main_package_kvpair = stanza.get_kvpair_element(
        (known_field.name, 0), use_get=True
    )
    if len(lint_state.binary_packages) == 1:
        lint_state.emit_diagnostic(
            kvpair_range,
            f"The {known_field.name} field is redundant for source packages that only build one binary package",
            "warning",
            "debputy",
            quickfixes=[propose_remove_range_quick_fix()],
        )
        return
    if doc_main_package_kvpair is not None:
        doc_main_package, value_range = extract_first_value_and_position(
            doc_main_package_kvpair,
            stanza_position,
        )
        if doc_main_package is None or doc_main_package in lint_state.binary_packages:
            return
        lint_state.emit_diagnostic(
            value_range,
            f"The {known_field.name} field must name a package listed in debian/control",
            "error",
            "debputy",
            quickfixes=[
                propose_correct_text_quick_fix(name)
                for name in lint_state.binary_packages
            ],
        )


def _single_line_span_range_relative_to_pos(
    span: tuple[int, int],
    relative_to: "TEPosition",
) -> Range:
    return TERange(
        TEPosition(
            relative_to.line_position,
            relative_to.cursor_position + span[0],
        ),
        TEPosition(
            relative_to.line_position,
            relative_to.cursor_position + span[1],
        ),
    )


def _check_extended_description_line(
    description_value_line: Deb822ValueLineElement,
    description_line_range_te: "TERange",
    package: str | None,
    lint_state: LintState,
) -> None:
    if description_value_line.comment_element is not None:
        # TODO: Fix this limitation (we get the content and the range wrong with comments.
        # They are rare inside a Description, so this is a 80:20 trade off
        return
    description_line_with_leading_space = (
        description_value_line.convert_to_text().rstrip()
    )
    try:
        idx = description_line_with_leading_space.index(
            "<insert long description, indented with spaces>"
        )
    except ValueError:
        pass
    else:
        template_span = idx, idx + len(
            "<insert long description, indented with spaces>"
        )
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                template_span,
                description_line_range_te.start_pos,
            ),
            "Unfilled or left-over template from dh_make",
            "error",
            "debputy",
        )
    if len(description_line_with_leading_space) > 80:
        # Policy says nothing here, but lintian has 80 characters as hard limit and that
        # probably matches the limitation of package manager UIs (TUIs/GUIs) somewhere.
        #
        # See also debputy#122
        span = 80, len(description_line_with_leading_space)
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                span,
                description_line_range_te.start_pos,
            ),
            "Package description line is too long; please line wrap it.",
            "warning",
            "debputy",
        )


def _check_synopsis(
    synopsis_value_line: Deb822ValueLineElement,
    synopsis_range_te: "TERange",
    field_name_range_te: "TERange",
    package: str | None,
    lint_state: LintState,
) -> None:
    # This function would compute range would be wrong if there is a comment
    assert synopsis_value_line.comment_element is None
    synopsis_text_with_leading_space = synopsis_value_line.convert_to_text().rstrip()
    if not synopsis_text_with_leading_space:
        lint_state.emit_diagnostic(
            field_name_range_te,
            "Package synopsis is missing",
            "warning",
            "debputy",
        )
        return
    synopsis_text_trimmed = synopsis_text_with_leading_space.lstrip()
    synopsis_offset = len(synopsis_text_with_leading_space) - len(synopsis_text_trimmed)
    starts_with_article = _RE_SYNOPSIS_STARTS_WITH_ARTICLE.search(
        synopsis_text_with_leading_space
    )
    # TODO: Handle ${...} expansion
    if starts_with_article:
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                starts_with_article.span(1),
                synopsis_range_te.start_pos,
            ),
            "Package synopsis starts with an article (a/an/the).",
            "warning",
            "DevRef 6.2.2",
        )
    if len(synopsis_text_trimmed) >= 80:
        # Policy says `certainly under 80 characters.`, so exactly 80 characters is considered bad too.
        span = synopsis_offset + 79, len(synopsis_text_with_leading_space)
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                span,
                synopsis_range_te.start_pos,
            ),
            "Package synopsis is too long.",
            "warning",
            "Policy 3.4.1",
        )
    if template_match := _RE_SYNOPSIS_IS_TEMPLATE.match(
        synopsis_text_with_leading_space
    ):
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                template_match.span(1),
                synopsis_range_te.start_pos,
            ),
            "Package synopsis is a placeholder",
            "warning",
            "debputy",
        )
    elif too_short_match := _RE_SYNOPSIS_IS_TOO_SHORT.match(
        synopsis_text_with_leading_space
    ):
        if not SUBSTVAR_RE.match(synopsis_text_with_leading_space.strip()):
            lint_state.emit_diagnostic(
                _single_line_span_range_relative_to_pos(
                    too_short_match.span(1),
                    synopsis_range_te.start_pos,
                ),
                "Package synopsis is too short",
                "warning",
                "debputy",
            )


def dctrl_description_validator(
    _known_field: "F",
    _deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    _field_name_range: "TERange",
    stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    value_lines = kvpair.value_element.value_lines
    if not value_lines:
        return
    package = stanza.get("Package")
    synopsis_value_line = value_lines[0]
    value_range_te = kvpair.value_element.range_in_parent().relative_to(
        kvpair_range_te.start_pos
    )
    synopsis_line_range_te = synopsis_value_line.range_in_parent().relative_to(
        value_range_te.start_pos
    )
    if synopsis_value_line.continuation_line_token is None:
        field_name_range_te = kvpair.field_token.range_in_parent().relative_to(
            kvpair_range_te.start_pos
        )
        _check_synopsis(
            synopsis_value_line,
            synopsis_line_range_te,
            field_name_range_te,
            package,
            lint_state,
        )
        description_lines = value_lines[1:]
    else:
        description_lines = value_lines
    for description_line in description_lines:
        description_line_range_te = description_line.range_in_parent().relative_to(
            value_range_te.start_pos
        )
        _check_extended_description_line(
            description_line,
            description_line_range_te,
            package,
            lint_state,
        )


def _has_packaging_expected_file(
    name: str,
    msg: str,
    severity: LintSeverity = "error",
) -> CustomFieldCheck:

    def _impl(
        _known_field: "F",
        _deb822_file: Deb822FileElement,
        _kvpair: Deb822KeyValuePairElement,
        kvpair_range_te: "TERange",
        _field_name_range_te: "TERange",
        _stanza: Deb822ParagraphElement,
        _stanza_position: "TEPosition",
        lint_state: LintState,
    ) -> None:
        debian_dir = lint_state.debian_dir
        if debian_dir is None:
            return
        cpy = debian_dir.lookup(name)
        if not cpy:
            lint_state.emit_diagnostic(
                kvpair_range_te,
                msg,
                severity,
                "debputy",
                diagnostic_applies_to_another_file=f"debian/{name}",
            )

    return _impl


_check_missing_debian_rules = _has_packaging_expected_file(
    "rules",
    'Missing debian/rules when the Build-Driver is unset or set to "debian-rules"',
)


def _has_build_instructions(
    known_field: "F",
    deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    field_name_range_te: "TERange",
    stanza: Deb822ParagraphElement,
    stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    if stanza.get("Build-Driver", "debian-rules").lower() != "debian-rules":
        return

    _check_missing_debian_rules(
        known_field,
        deb822_file,
        kvpair,
        kvpair_range_te,
        field_name_range_te,
        stanza,
        stanza_position,
        lint_state,
    )


def _canonical_maintainer_name(
    known_field: "F",
    _deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    _field_name_range_te: "TERange",
    _stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    value_element_pos = kvpair.value_element.position_in_parent().relative_to(
        kvpair_range_te.start_pos
    )
    try:
        interpreted_value = kvpair.interpret_as(
            known_field.field_value_class.interpreter()
        )
    except ValueError:
        return

    for part in interpreted_value.iter_parts():
        if part.is_separator or part.is_whitespace or part.is_whitespace:
            continue
        name_and_email = part.convert_to_text()
        try:
            email_start = name_and_email.rindex("<")
            email_end = name_and_email.rindex(">")
            email = name_and_email[email_start + 1 : email_end]
        except IndexError:
            continue

        pref = lint_state.maint_preference_table.maintainer_preferences.get(email)
        if pref is None or not pref.canonical_name:
            continue

        expected = f"{pref.canonical_name} <{email}>"
        if expected == name_and_email:
            continue
        value_range_te = part.range_in_parent().relative_to(value_element_pos)
        lint_state.emit_diagnostic(
            value_range_te,
            "Non-canonical or incorrect spelling of maintainer name",
            "informational",
            "debputy",
            quickfixes=[propose_correct_text_quick_fix(expected)],
        )


def _maintainer_field_validator(
    known_field: "F",
    _deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    _field_name_range_te: "TERange",
    _stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:

    value_element_pos = kvpair.value_element.position_in_parent().relative_to(
        kvpair_range_te.start_pos
    )
    interpreted_value = kvpair.interpret_as(known_field.field_value_class.interpreter())
    for part in interpreted_value.iter_parts():
        if not part.is_separator:
            continue
        value_range_te = part.range_in_parent().relative_to(value_element_pos)
        severity = known_field.unknown_value_severity
        assert severity is not None
        # TODO: Check for a follow up maintainer and based on that the quick fix is either
        #  to remove the dead separator OR move the trailing data into `Uploaders`
        lint_state.emit_diagnostic(
            value_range_te,
            'The "Maintainer" field has a trailing separator, but it is a single value field.',
            severity,
            known_field.unknown_value_authority,
        )


def _use_https_instead_of_http(
    known_field: "F",
    _deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    _field_name_range_te: "TERange",
    _stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    value_element_pos = kvpair.value_element.position_in_parent().relative_to(
        kvpair_range_te.start_pos
    )
    interpreted_value = kvpair.interpret_as(known_field.field_value_class.interpreter())
    for part in interpreted_value.iter_parts():
        value = part.convert_to_text()
        if not value.startswith("http://"):
            continue
        value_range_te = part.range_in_parent().relative_to(value_element_pos)
        problem_range_te = TERange.between(
            value_range_te.start_pos,
            TEPosition(
                value_range_te.start_pos.line_position,
                value_range_te.start_pos.cursor_position + 7,
            ),
        )
        lint_state.emit_diagnostic(
            problem_range_te,
            "The Format URL should use https:// rather than http://",
            "warning",
            "debputy",
            quickfixes=[propose_correct_text_quick_fix("https://")],
        )


def _each_value_match_regex_validation(
    regex: re.Pattern,
    *,
    diagnostic_severity: LintSeverity = "error",
    authority_reference: str | None = None,
) -> CustomFieldCheck:

    def _validator(
        known_field: "F",
        _deb822_file: Deb822FileElement,
        kvpair: Deb822KeyValuePairElement,
        kvpair_range_te: "TERange",
        _field_name_range_te: "TERange",
        _stanza: Deb822ParagraphElement,
        _stanza_position: "TEPosition",
        lint_state: LintState,
    ) -> None:
        nonlocal authority_reference
        interpreter = known_field.field_value_class.interpreter()
        if interpreter is None:
            raise AssertionError(
                f"{known_field.name} has field type {known_field.field_value_class}, which cannot be"
                f" regex validated since it does not have a tokenization"
            )
        auth_ref = (
            authority_reference
            if authority_reference is not None
            else known_field.unknown_value_authority
        )

        value_element_pos = kvpair.value_element.position_in_parent().relative_to(
            kvpair_range_te.start_pos
        )
        for value_ref in kvpair.interpret_as(interpreter).iter_value_references():
            v = value_ref.value
            m = regex.fullmatch(v)
            if m is not None:
                continue

            if "${" in v:
                # Ignore substvars
                continue

            section_value_loc = value_ref.locatable
            value_range_te = section_value_loc.range_in_parent().relative_to(
                value_element_pos
            )
            lint_state.emit_diagnostic(
                value_range_te,
                f'The value "{v}" does not match the regex {regex.pattern}.',
                diagnostic_severity,
                auth_ref,
            )

    return _validator


_DEP_OR_RELATION = re.compile(r"[|]")
_DEP_RELATION_CLAUSE = re.compile(
    r"""
    ^
    \s*
    (?P<name_arch_qual>[-+.a-zA-Z0-9${}:]{2,})
    \s*
    (?: [(] \s* (?P<operator>>>|>=|>|=|<|<=|<<) \s* (?P<version> [0-9$][^)]*|[$][{]\S+[}]) \s* [)] \s* )?
    (?: \[ (?P<arch_restriction> [\s!\w\-]+) ] \s*)?
    (?: < (?P<build_profile_restriction> .+ ) > \s*)?
    ((?P<garbage>\S.*)\s*)?
    $
""",
    re.VERBOSE | re.MULTILINE,
)


def _span_to_te_range(
    text: str,
    start_pos: int,
    end_pos: int,
) -> TERange:
    prefix = text[0:start_pos]
    prefix_plus_text = text[0:end_pos]

    start_line = prefix.count("\n")
    if start_line:
        start_newline_offset = prefix.rindex("\n")
        # +1 to skip past the newline
        start_cursor_pos = start_pos - (start_newline_offset + 1)
    else:
        start_cursor_pos = start_pos

    end_line = prefix_plus_text.count("\n")
    if end_line == start_line:
        end_cursor_pos = start_cursor_pos + (end_pos - start_pos)
    else:
        end_newline_offset = prefix_plus_text.rindex("\n")
        end_cursor_pos = end_pos - (end_newline_offset + 1)

    return TERange(
        TEPosition(
            start_line,
            start_cursor_pos,
        ),
        TEPosition(
            end_line,
            end_cursor_pos,
        ),
    )


def _split_w_spans(
    v: str,
    sep: str,
    *,
    offset: int = 0,
) -> Sequence[tuple[str, int, int]]:
    separator_size = len(sep)
    parts = v.split(sep)
    for part in parts:
        size = len(part)
        end_offset = offset + size
        yield part, offset, end_offset
        offset = end_offset + separator_size


_COLLAPSE_WHITESPACE = re.compile(r"\s+")


def _cleanup_rel(rel: str) -> str:
    return _COLLAPSE_WHITESPACE.sub(" ", rel.strip())


def _text_to_te_position(text: str) -> "TEPosition":
    newlines = text.count("\n")
    if not newlines:
        return TEPosition(
            newlines,
            len(text),
        )
    last_newline_offset = text.rindex("\n")
    line_offset = len(text) - (last_newline_offset + 1)
    return TEPosition(
        newlines,
        line_offset,
    )


@dataclasses.dataclass(slots=True, frozen=True)
class Relation:
    name: str
    arch_qual: str | None = None
    version_operator: str | None = None
    version: str | None = None
    arch_restriction: str | None = None
    build_profile_restriction: str | None = None
    # These offsets are intended to show the relation itself. They are not
    # the relation boundary offsets (they will omit leading whitespace as
    # an example).
    content_display_offset: int = -1
    content_display_end_offset: int = -1


def relation_key_variations(
    relation: Relation,
) -> tuple[str, str | None, str | None]:
    operator_variants = (
        [relation.version_operator, None]
        if relation.version_operator is not None
        else [None]
    )
    arch_qual_variants = (
        [relation.arch_qual, None]
        if relation.arch_qual is not None and relation.arch_qual != "any"
        else [None]
    )
    for arch_qual, version_operator in itertools.product(
        arch_qual_variants,
        operator_variants,
    ):
        yield relation.name, arch_qual, version_operator


def dup_check_relations(
    known_field: "F",
    relations: Sequence[Relation],
    raw_value_masked_comments: str,
    value_element_pos: "TEPosition",
    lint_state: LintState,
) -> None:
    overlap_table = {}
    for relation in relations:
        version_operator = relation.version_operator
        arch_qual = relation.arch_qual
        if relation.arch_restriction or relation.build_profile_restriction:
            continue

        for relation_key in relation_key_variations(relation):
            prev_relation = overlap_table.get(relation_key)
            if prev_relation is None:
                overlap_table[relation_key] = relation
            else:
                prev_version_operator = prev_relation.version_operator

                if (
                    prev_version_operator
                    and version_operator
                    and prev_version_operator[0] != version_operator[0]
                    and version_operator[0] in ("<", ">")
                    and prev_version_operator[0] in ("<", ">")
                ):
                    # foo (>= 1), foo (<< 2) and similar should not trigger a warning.
                    continue

                prev_arch_qual = prev_relation.arch_qual
                if (
                    arch_qual != prev_arch_qual
                    and prev_arch_qual != "any"
                    and arch_qual != "any"
                ):
                    # foo:amd64 != foo:native and that might matter - especially for "libfoo-dev, libfoo-dev:native"
                    #
                    # This check is probably a too forgiving in some corner cases.
                    continue

                if (
                    known_field.name == "Provides"
                    and version_operator == "="
                    and prev_version_operator == version_operator
                    and relation.version != prev_relation.version
                ):
                    # Provides: foo (= 1), foo (= 2) is legal and not mergeable
                    continue

                orig_relation_range = TERange(
                    _text_to_te_position(
                        raw_value_masked_comments[
                            : prev_relation.content_display_offset
                        ]
                    ),
                    _text_to_te_position(
                        raw_value_masked_comments[
                            : prev_relation.content_display_end_offset
                        ]
                    ),
                ).relative_to(value_element_pos)

                duplicate_relation_range = TERange(
                    _text_to_te_position(
                        raw_value_masked_comments[: relation.content_display_offset]
                    ),
                    _text_to_te_position(
                        raw_value_masked_comments[: relation.content_display_end_offset]
                    ),
                ).relative_to(value_element_pos)

                lint_state.emit_diagnostic(
                    duplicate_relation_range,
                    "Duplicate relationship. Merge with the previous relationship",
                    "warning",
                    known_field.unknown_value_authority,
                    related_information=[
                        lint_state.related_diagnostic_information(
                            orig_relation_range,
                            "The previous definition",
                        ),
                    ],
                )
                # We only emit for the first duplicate "key" for each relation. Odds are remaining
                # keys point to the same match. Even if they do not, it does not really matter as
                # we already pointed out an issue for the user to follow up on.
                break


def _dctrl_check_dep_version_operator(
    known_field: "F",
    version_operator: str,
    version_operator_span: tuple[int, int],
    version_operators: frozenset[str],
    raw_value_masked_comments: str,
    offset: int,
    value_element_pos: "TEPosition",
    lint_state: LintState,
) -> bool:
    if (
        version_operators
        and version_operator is not None
        and version_operator not in version_operators
    ):
        v_start_offset = offset + version_operator_span[0]
        v_end_offset = offset + version_operator_span[1]
        version_problem_range_te = TERange(
            _text_to_te_position(raw_value_masked_comments[:v_start_offset]),
            _text_to_te_position(raw_value_masked_comments[:v_end_offset]),
        ).relative_to(value_element_pos)

        sorted_version_operators = sorted(version_operators)

        excluding_equal = f"{version_operator}{version_operator}"
        including_equal = f"{version_operator}="

        if version_operator in (">", "<") and (
            excluding_equal in version_operators or including_equal in version_operators
        ):
            lint_state.emit_diagnostic(
                version_problem_range_te,
                f'Obsolete version operator "{version_operator}" that is no longer supported.',
                "error",
                "Policy 7.1",
                quickfixes=[
                    propose_correct_text_quick_fix(n)
                    for n in (excluding_equal, including_equal)
                    if not version_operators or n in version_operators
                ],
            )
        else:
            lint_state.emit_diagnostic(
                version_problem_range_te,
                f'The version operator "{version_operator}" is not allowed in {known_field.name}',
                "error",
                known_field.unknown_value_authority,
                quickfixes=[
                    propose_correct_text_quick_fix(n) for n in sorted_version_operators
                ],
            )
        return True
    return False


def _dctrl_validate_dep(
    known_field: "DF",
    _deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    _field_name_range: "TERange",
    _stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    value_element_pos = kvpair.value_element.position_in_parent().relative_to(
        kvpair_range_te.start_pos
    )
    raw_value_with_comments = kvpair.value_element.convert_to_text()
    raw_value_masked_comments = "".join(
        (line if not line.startswith("#") else (" " * (len(line) - 1)) + "\n")
        for line in raw_value_with_comments.splitlines(keepends=True)
    )
    if isinstance(known_field, DctrlRelationshipKnownField):
        version_operators = known_field.allowed_version_operators
        supports_or_relation = known_field.supports_or_relation
    else:
        version_operators = frozenset({">>", ">=", "=", "<=", "<<"})
        supports_or_relation = True

    relation_dup_table = collections.defaultdict(list)

    for rel, rel_offset, rel_end_offset in _split_w_spans(
        raw_value_masked_comments, ","
    ):
        sub_relations = []
        for or_rel, offset, end_offset in _split_w_spans(rel, "|", offset=rel_offset):
            if or_rel.isspace():
                continue
            if sub_relations and not supports_or_relation:
                separator_range_te = TERange(
                    _text_to_te_position(raw_value_masked_comments[: offset - 1]),
                    _text_to_te_position(raw_value_masked_comments[:offset]),
                ).relative_to(value_element_pos)
                lint_state.emit_diagnostic(
                    separator_range_te,
                    f'The field {known_field.name} does not support "|" (OR) in relations.',
                    "error",
                    known_field.unknown_value_authority,
                )
            m = _DEP_RELATION_CLAUSE.fullmatch(or_rel)

            if m is not None:
                garbage = m.group("garbage")
                version_operator = m.group("operator")
                version_operator_span = m.span("operator")
                if _dctrl_check_dep_version_operator(
                    known_field,
                    version_operator,
                    version_operator_span,
                    version_operators,
                    raw_value_masked_comments,
                    offset,
                    value_element_pos,
                    lint_state,
                ):
                    sub_relations.append(Relation("<BROKEN>"))
                else:
                    name_arch_qual = m.group("name_arch_qual")
                    if ":" in name_arch_qual:
                        name, arch_qual = name_arch_qual.split(":", 1)
                    else:
                        name = name_arch_qual
                        arch_qual = None
                    sub_relations.append(
                        Relation(
                            name,
                            arch_qual=arch_qual,
                            version_operator=version_operator,
                            version=m.group("version"),
                            arch_restriction=m.group("build_profile_restriction"),
                            build_profile_restriction=m.group(
                                "build_profile_restriction"
                            ),
                            content_display_offset=offset + m.start("name_arch_qual"),
                            # TODO: This should be trimmed in the end.
                            content_display_end_offset=rel_end_offset,
                        )
                    )
            else:
                garbage = None
                sub_relations.append(Relation("<BROKEN>"))

            if m is not None and not garbage:
                continue
            if m is not None:
                garbage_span = m.span("garbage")
                garbage_start, garbage_end = garbage_span
                error_start_offset = offset + garbage_start
                error_end_offset = offset + garbage_end
                garbage_part = raw_value_masked_comments[
                    error_start_offset:error_end_offset
                ]
            else:
                garbage_part = None
                error_start_offset = offset
                error_end_offset = end_offset

            problem_range_te = TERange(
                _text_to_te_position(raw_value_masked_comments[:error_start_offset]),
                _text_to_te_position(raw_value_masked_comments[:error_end_offset]),
            ).relative_to(value_element_pos)

            if garbage_part is not None:
                if _DEP_RELATION_CLAUSE.fullmatch(garbage_part) is not None:
                    msg = (
                        "Trailing data after a relationship that might be a second relationship."
                        " Is a separator missing before this part?"
                    )
                else:
                    msg = "Parse error of the relationship. Either a syntax error or a missing separator somewhere."
                lint_state.emit_diagnostic(
                    problem_range_te,
                    msg,
                    "error",
                    known_field.unknown_value_authority,
                )
            else:
                dep = _cleanup_rel(
                    raw_value_masked_comments[error_start_offset:error_end_offset]
                )
                lint_state.emit_diagnostic(
                    problem_range_te,
                    f'Could not parse "{dep}" as a dependency relation.',
                    "error",
                    known_field.unknown_value_authority,
                )
        if (
            len(sub_relations) == 1
            and (relation := sub_relations[0]).name != "<BROKEN>"
        ):
            # We ignore OR-relations in the dup-check for now. We also skip relations with problems.
            relation_dup_table[relation.name].append(relation)

    for relations in relation_dup_table.values():
        if len(relations) > 1:
            dup_check_relations(
                known_field,
                relations,
                raw_value_masked_comments,
                value_element_pos,
                lint_state,
            )


def _rrr_build_driver_mismatch(
    _known_field: "F",
    _deb822_file: Deb822FileElement,
    _kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    _field_name_range: "TERange",
    stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    dr = stanza.get("Build-Driver", "debian-rules")
    if dr != "debian-rules":
        lint_state.emit_diagnostic(
            kvpair_range_te,
            f'The Rules-Requires-Root field is irrelevant for the Build-Driver "{dr}".',
            "informational",
            "debputy",
            quickfixes=[
                propose_remove_range_quick_fix(
                    proposed_title="Remove Rules-Requires-Root"
                )
            ],
        )


class Dep5Matcher(BasenameGlobMatch):
    def __init__(self, basename_glob: str) -> None:
        super().__init__(
            basename_glob,
            only_when_in_directory=None,
            path_type=None,
            recursive_match=False,
        )


def _match_dep5_segment(
    current_dir: VirtualPathBase, basename_glob: str
) -> Iterable[VirtualPathBase]:
    if "*" in basename_glob or "?" in basename_glob:
        return Dep5Matcher(basename_glob).finditer(current_dir)
    else:
        res = current_dir.get(basename_glob)
        if res is None:
            return tuple()
        return (res,)


_RE_SLASHES = re.compile(r"//+")


def _dep5_unnecessary_symbols(
    value: str,
    value_range: TERange,
    lint_state: LintState,
) -> None:
    slash_check_index = 0
    if value.startswith(("./", "/")):
        prefix_len = 1 if value[0] == "/" else 2
        if value[prefix_len - 1 : prefix_len + 2].startswith("//"):
            _, slashes_end = _RE_SLASHES.search(value).span()
            prefix_len = slashes_end

        slash_check_index = prefix_len
        prefix_range = TERange(
            value_range.start_pos,
            TEPosition(
                value_range.start_pos.line_position,
                value_range.start_pos.cursor_position + prefix_len,
            ),
        )
        lint_state.emit_diagnostic(
            prefix_range,
            f'Unnecessary prefix "{value[0:prefix_len]}"',
            "warning",
            "debputy",
            quickfixes=[
                propose_remove_range_quick_fix(
                    proposed_title=f'Delete "{value[0:prefix_len]}"'
                )
            ],
        )

    for m in _RE_SLASHES.finditer(value, slash_check_index):
        m_start, m_end = m.span(0)

        prefix_range = TERange(
            TEPosition(
                value_range.start_pos.line_position,
                value_range.start_pos.cursor_position + m_start,
            ),
            TEPosition(
                value_range.start_pos.line_position,
                value_range.start_pos.cursor_position + m_end,
            ),
        )
        lint_state.emit_diagnostic(
            prefix_range,
            'Simplify to a single "/"',
            "warning",
            "debputy",
            quickfixes=[propose_correct_text_quick_fix("/")],
        )


def _dep5_files_check(
    known_field: "F",
    _deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    _field_name_range: "TERange",
    _stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    interpreter = known_field.field_value_class.interpreter()
    assert interpreter is not None
    full_value_range = kvpair.value_element.range_in_parent().relative_to(
        kvpair_range_te.start_pos
    )
    values_with_ranges = []
    for value_ref in kvpair.interpret_as(interpreter).iter_value_references():
        value_range = value_ref.locatable.range_in_parent().relative_to(
            full_value_range.start_pos
        )
        value = value_ref.value
        values_with_ranges.append((value_ref.value, value_range))
        _dep5_unnecessary_symbols(value, value_range, lint_state)

    source_root = lint_state.source_root
    if source_root is None:
        return
    i = 0
    limit = len(values_with_ranges)
    while i < limit:
        value, value_range = values_with_ranges[i]
        i += 1


_HOMEPAGE_CLUTTER_RE = re.compile(r"<(?:UR[LI]:)?(.*)>")
_URI_RE = re.compile(r"(?P<protocol>[a-z0-9]+)://(?P<host>[^\s/+]+)(?P<path>/[^\s?]*)?")
_KNOWN_HTTPS_HOSTS = frozenset(
    [
        "debian.org",
        "bioconductor.org",
        "cran.r-project.org",
        "github.com",
        "gitlab.com",
        "metacpan.org",
        "gnu.org",
    ]
)
_REPLACED_HOSTS = frozenset({"alioth.debian.org"})
_NO_DOT_GIT_HOMEPAGE_HOSTS = frozenset(
    {
        "salsa.debian.org",
        "github.com",
        "gitlab.com",
    }
)


def _is_known_host(host: str, known_hosts: Container[str]) -> bool:
    if host in known_hosts:
        return True
    while host:
        try:
            idx = host.index(".")
            host = host[idx + 1 :]
        except ValueError:
            break
        if host in known_hosts:
            return True
    return False


def _validate_homepage_field(
    _known_field: "F",
    _deb822_file: Deb822FileElement,
    kvpair: Deb822KeyValuePairElement,
    kvpair_range_te: "TERange",
    _field_name_range_te: "TERange",
    _stanza: Deb822ParagraphElement,
    _stanza_position: "TEPosition",
    lint_state: LintState,
) -> None:
    value = kvpair.value_element.convert_to_text()
    offset = 0
    homepage = value
    if "<" in value and (m := _HOMEPAGE_CLUTTER_RE.search(value)):
        expected_value = m.group(1)
        quickfixes = []
        if expected_value:
            homepage = expected_value.strip()
            offset = m.start(1)
            quickfixes.append(propose_correct_text_quick_fix(expected_value))
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                m.span(),
                kvpair.value_element.position_in_parent().relative_to(
                    kvpair_range_te.start_pos
                ),
            ),
            "Superfluous URL/URI wrapping",
            "informational",
            "Policy 5.6.23",
            quickfixes=quickfixes,
        )
        # Note falling through here can cause "two rounds" for debputy lint --auto-fix
    m = _URI_RE.search(homepage)
    if not m:
        return
    # TODO relative to lintian: `bad-homepage` and most of the `fields/bad-homepages` hints.
    protocol = m.group("protocol")
    host = m.group("host")
    path = m.group("path") or ""
    if _is_known_host(host, _REPLACED_HOSTS):
        span = m.span("host")
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                (span[0] + offset, span[1] + offset),
                kvpair.value_element.position_in_parent().relative_to(
                    kvpair_range_te.start_pos
                ),
            ),
            f'The server "{host}" is no longer in use.',
            "warning",
            "debputy",
        )
        return
    if (
        protocol == "ftp"
        or protocol == "http"
        and _is_known_host(host, _KNOWN_HTTPS_HOSTS)
    ):
        span = m.span("protocol")
        if protocol == "ftp" and not _is_known_host(host, _KNOWN_HTTPS_HOSTS):
            msg = "Insecure protocol for website (check if a https:// variant is available)"
            quickfixes = []
        else:
            msg = "Replace with https://. The host is known to support https"
            quickfixes = [propose_correct_text_quick_fix("https")]
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                (span[0] + offset, span[1] + offset),
                kvpair.value_element.position_in_parent().relative_to(
                    kvpair_range_te.start_pos
                ),
            ),
            msg,
            "pedantic",
            "debputy",
            quickfixes=quickfixes,
        )
    if path.endswith(".git") and _is_known_host(host, _NO_DOT_GIT_HOMEPAGE_HOSTS):
        span = m.span("path")
        msg = "Unnecessary suffix"
        quickfixes = [propose_correct_text_quick_fix(path[:-4])]
        lint_state.emit_diagnostic(
            _single_line_span_range_relative_to_pos(
                (span[1] - 4 + offset, span[1] + offset),
                kvpair.value_element.position_in_parent().relative_to(
                    kvpair_range_te.start_pos
                ),
            ),
            msg,
            "pedantic",
            "debputy",
            quickfixes=quickfixes,
        )


def _combined_custom_field_check(*checks: CustomFieldCheck) -> CustomFieldCheck:
    def _validator(
        known_field: "F",
        deb822_file: Deb822FileElement,
        kvpair: Deb822KeyValuePairElement,
        kvpair_range_te: "TERange",
        field_name_range_te: "TERange",
        stanza: Deb822ParagraphElement,
        stanza_position: "TEPosition",
        lint_state: LintState,
    ) -> None:
        for check in checks:
            check(
                known_field,
                deb822_file,
                kvpair,
                kvpair_range_te,
                field_name_range_te,
                stanza,
                stanza_position,
                lint_state,
            )

    return _validator


@dataclasses.dataclass(slots=True, frozen=True)
class PackageNameSectionRule:
    section: str
    check: Callable[[str], bool]


def _package_name_section_rule(
    section: str,
    check: Callable[[str], bool] | re.Pattern,
    *,
    confirm_re: re.Pattern | None = None,
) -> PackageNameSectionRule:
    if confirm_re is not None:
        assert callable(check)

        def _impl(v: str) -> bool:
            return check(v) and confirm_re.search(v)

    elif isinstance(check, re.Pattern):

        def _impl(v: str) -> bool:
            return check.search(v) is not None

    else:
        _impl = check

    return PackageNameSectionRule(section, _impl)


# rules: order is important (first match wins in case of a conflict)
_PKGNAME_VS_SECTION_RULES = [
    _package_name_section_rule("debian-installer", lambda n: n.endswith("-udeb")),
    _package_name_section_rule("doc", lambda n: n.endswith(("-doc", "-docs"))),
    _package_name_section_rule("debug", lambda n: n.endswith(("-dbg", "-dbgsym"))),
    _package_name_section_rule(
        "httpd",
        lambda n: n.startswith(("lighttpd-mod", "libapache2-mod-", "libnginx-mod-")),
    ),
    _package_name_section_rule("gnustep", lambda n: n.startswith("gnustep-")),
    _package_name_section_rule(
        "gnustep",
        lambda n: n.endswith(
            (
                ".framework",
                ".framework-common",
                ".tool",
                ".tool-common",
                ".app",
                ".app-common",
            )
        ),
    ),
    _package_name_section_rule("embedded", lambda n: n.startswith("moblin-")),
    _package_name_section_rule("javascript", lambda n: n.startswith("node-")),
    _package_name_section_rule(
        "zope",
        lambda n: n.startswith(("python-zope", "python3-zope", "zope")),
    ),
    _package_name_section_rule(
        "python",
        lambda n: n.startswith(("python-", "python3-")),
    ),
    _package_name_section_rule(
        "gnu-r",
        lambda n: n.startswith(("r-cran-", "r-bioc-", "r-other-")),
    ),
    _package_name_section_rule("editors", lambda n: n.startswith("elpa-")),
    _package_name_section_rule("lisp", lambda n: n.startswith("cl-")),
    _package_name_section_rule(
        "lisp",
        lambda n: "-elisp-" in n or n.endswith("-elisp"),
    ),
    _package_name_section_rule(
        "lisp",
        lambda n: n.startswith("lib") and n.endswith("-guile"),
    ),
    _package_name_section_rule("lisp", lambda n: n.startswith("guile-")),
    _package_name_section_rule("golang", lambda n: n.startswith("golang-")),
    _package_name_section_rule(
        "perl",
        lambda n: n.startswith("lib") and n.endswith("-perl"),
    ),
    _package_name_section_rule(
        "cli-mono",
        lambda n: n.startswith("lib") and n.endswith(("-cil", "-cil-dev")),
    ),
    _package_name_section_rule(
        "java",
        lambda n: n.startswith("lib") and n.endswith(("-java", "-gcj", "-jni")),
    ),
    _package_name_section_rule(
        "php",
        lambda n: n.startswith(("libphp", "php")),
        confirm_re=re.compile(r"^(?:lib)?php(?:\d(?:\.\d)?)?-"),
    ),
    _package_name_section_rule(
        "php", lambda n: n.startswith("lib-") and n.endswith("-php")
    ),
    _package_name_section_rule(
        "haskell",
        lambda n: n.startswith(("haskell-", "libhugs-", "libghc-", "libghc6-")),
    ),
    _package_name_section_rule(
        "ruby",
        lambda n: "-ruby" in n,
        confirm_re=re.compile(r"^lib.*-ruby(?:1\.\d)?$"),
    ),
    _package_name_section_rule("ruby", lambda n: n.startswith("ruby-")),
    _package_name_section_rule(
        "rust",
        lambda n: n.startswith("librust-") and n.endswith("-dev"),
    ),
    _package_name_section_rule("rust", lambda n: n.startswith("rust-")),
    _package_name_section_rule(
        "ocaml",
        lambda n: n.startswith("lib-") and n.endswith(("-ocaml-dev", "-camlp4-dev")),
    ),
    _package_name_section_rule("javascript", lambda n: n.startswith("libjs-")),
    _package_name_section_rule(
        "interpreters",
        lambda n: n.startswith("lib-") and n.endswith(("-tcl", "-lua", "-gst")),
    ),
    _package_name_section_rule(
        "introspection",
        lambda n: n.startswith("gir-"),
        confirm_re=re.compile(r"^gir\d+\.\d+-.*-\d+\.\d+$"),
    ),
    _package_name_section_rule(
        "fonts",
        lambda n: n.startswith(("xfonts-", "fonts-", "ttf-")),
    ),
    _package_name_section_rule("admin", lambda n: n.startswith(("libnss-", "libpam-"))),
    _package_name_section_rule(
        "localization",
        lambda n: n.startswith(
            (
                "aspell-",
                "hunspell-",
                "myspell-",
                "mythes-",
                "dict-freedict-",
                "gcompris-sound-",
            )
        ),
    ),
    _package_name_section_rule(
        "localization",
        lambda n: n.startswith("hyphen-"),
        confirm_re=re.compile(r"^hyphen-[a-z]{2}(?:-[a-z]{2})?$"),
    ),
    _package_name_section_rule(
        "localization",
        lambda n: "-l10n-" in n or n.endswith("-l10n"),
    ),
    _package_name_section_rule("kernel", lambda n: n.endswith(("-dkms", "-firmware"))),
    _package_name_section_rule(
        "libdevel",
        lambda n: n.startswith("lib") and n.endswith(("-dev", "-headers")),
    ),
    _package_name_section_rule(
        "libs",
        lambda n: n.startswith("lib"),
        confirm_re=re.compile(r"^lib.*\d[ad]?$"),
    ),
]


# Fiddling with the package name can cause a lot of changes (diagnostic scans), so we have an upper bound
# on the cache. The number is currently just taken out of a hat.
@functools.lru_cache(64)
def package_name_to_section(name: str) -> str | None:
    for rule in _PKGNAME_VS_SECTION_RULES:
        if rule.check(name):
            return rule.section
    return None


def _unknown_value_check(
    field_name: str,
    value: str,
    known_values: Mapping[str, Keyword],
    unknown_value_severity: LintSeverity | None,
) -> tuple[Keyword | None, str | None, LintSeverity | None, Any | None]:
    known_value = known_values.get(value)
    message = None
    severity = unknown_value_severity
    fix_data = None
    if known_value is None:
        candidates = detect_possible_typo(
            value,
            known_values,
        )
        if len(known_values) < 5:
            values = ", ".join(sorted(known_values))
            hint_text = f" Known values for this field: {values}"
        else:
            hint_text = ""
        fix_data = None
        severity = unknown_value_severity
        fix_text = hint_text
        if candidates:
            match = candidates[0]
            if len(candidates) == 1:
                known_value = known_values[match]
            fix_text = (
                f' It is possible that the value is a typo of "{match}".{fix_text}'
            )
            fix_data = [propose_correct_text_quick_fix(m) for m in candidates]
        elif severity is None:
            return None, None, None, None
        if severity is None:
            severity = cast("LintSeverity", "warning")
            # It always has leading whitespace
            message = fix_text.strip()
        else:
            message = f'The value "{value}" is not supported in {field_name}.{fix_text}'
    return known_value, message, severity, fix_data


def _dep5_escape_path(path: str) -> str:
    return path.replace(" ", "?")


def _noop_escape_path(path: str) -> str:
    return path


def _should_ignore_dir(
    path: VirtualPath,
    *,
    supports_dir_match: bool = False,
    match_non_persistent_paths: bool = False,
) -> bool:
    if not supports_dir_match and not any(path.iterdir()):
        return True
    cachedir_tag = path.get("CACHEDIR.TAG")
    if (
        not match_non_persistent_paths
        and cachedir_tag is not None
        and cachedir_tag.is_file
    ):
        # https://bford.info/cachedir/
        with cachedir_tag.open(byte_io=True, buffering=64) as fd:
            start = fd.read(43)
        if start == b"Signature: 8a477f597d28d172789f06886806bc55":
            return True
    return False


@dataclasses.dataclass(slots=True)
class Deb822KnownField:
    name: str
    field_value_class: FieldValueClass
    warn_if_default: bool = True
    unknown_value_authority: str = "debputy"
    missing_field_authority: str = "debputy"
    replaced_by: str | None = None
    deprecated_with_no_replacement: bool = False
    missing_field_severity: LintSeverity | None = None
    default_value: str | None = None
    known_values: Mapping[str, Keyword] | None = None
    unknown_value_severity: LintSeverity | None = "error"
    translation_context: str = ""
    # One-line description for space-constrained docs (such as completion docs)
    synopsis: str | None = None
    usage_hint: UsageHint | None = None
    long_description: str | None = None
    spellcheck_value: bool = False
    inheritable_from_other_stanza: bool = False
    show_as_inherited: bool = True
    custom_field_check: CustomFieldCheck | None = None
    can_complete_field_in_stanza: None | (
        Callable[[Iterable[Deb822ParagraphElement]], bool]
    ) = None
    is_substvars_disabled_even_if_allowed_by_stanza: bool = False
    is_alias_of: str | None = None
    is_completion_suggestion: bool = True

    def synopsis_translated(
        self, translation_provider: Union["DebputyLanguageServer", "LintState"]
    ) -> str | None:
        if self.synopsis is None:
            return None
        return translation_provider.translation(LSP_DATA_DOMAIN).pgettext(
            self.translation_context,
            self.synopsis,
        )

    def long_description_translated(
        self, translation_provider: Union["DebputyLanguageServer", "LintState"]
    ) -> str | None:
        if self.long_description_translated is None:
            return None
        return translation_provider.translation(LSP_DATA_DOMAIN).pgettext(
            self.translation_context,
            self.long_description,
        )

    def _can_complete_field_in_stanza(
        self,
        stanza_parts: Sequence[Deb822ParagraphElement],
    ) -> bool:
        if not self.is_completion_suggestion:
            return False
        return (
            self.can_complete_field_in_stanza is None
            or self.can_complete_field_in_stanza(stanza_parts)
        )

    def complete_field(
        self,
        lint_state: LintState,
        stanza_parts: Sequence[Deb822ParagraphElement],
        markdown_kind: MarkupKind,
    ) -> CompletionItem | None:
        if not self._can_complete_field_in_stanza(stanza_parts):
            return None
        name = self.name
        complete_as = name + ": "
        options = self.value_options_for_completer(
            lint_state,
            stanza_parts,
            "",
            markdown_kind,
            is_completion_for_field=True,
        )
        if options is not None and len(options) == 1:
            value = options[0].insert_text
            if value is not None:
                complete_as += value
        tags = []
        is_deprecated = False
        if self.replaced_by or self.deprecated_with_no_replacement:
            is_deprecated = True
            tags.append(CompletionItemTag.Deprecated)

        doc = self.long_description
        if doc:
            doc = MarkupContent(
                value=doc,
                kind=markdown_kind,
            )
        else:
            doc = None

        return CompletionItem(
            name,
            insert_text=complete_as,
            deprecated=is_deprecated,
            tags=tags,
            detail=format_comp_item_synopsis_doc(
                self.usage_hint,
                self.synopsis_translated(lint_state),
                is_deprecated,
            ),
            documentation=doc,
        )

    def _complete_files(
        self,
        base_dir: VirtualPathBase | None,
        value_being_completed: str,
        *,
        is_dep5_file_list: bool = False,
        supports_dir_match: bool = False,
        supports_spaces_in_filename: bool = False,
        match_non_persistent_paths: bool = False,
    ) -> Sequence[CompletionItem] | None:
        _info(f"_complete_files: {base_dir.fs_path} - {value_being_completed!r}")
        if base_dir is None or not base_dir.is_dir:
            return None

        if is_dep5_file_list:
            supports_spaces_in_filename = True
            supports_dir_match = False
            match_non_persistent_paths = False

        if value_being_completed == "":
            current_dir = base_dir
            unmatched_parts: Sequence[str] = ()
        else:
            current_dir, unmatched_parts = base_dir.attempt_lookup(
                value_being_completed
            )

        if len(unmatched_parts) > 1:
            # Unknown directory part / glob, and we currently do not deal with that.
            return None
        if len(unmatched_parts) == 1 and unmatched_parts[0] == "*":
            # Avoid convincing the client to remove the star (seen with emacs)
            return None
        items = []

        path_escaper = _dep5_escape_path if is_dep5_file_list else _noop_escape_path

        for child in current_dir.iterdir():
            if child.is_symlink and is_dep5_file_list:
                continue
            if not supports_spaces_in_filename and (
                " " in child.name or "\t" in child.name
            ):
                continue
            sort_text = (
                f"z-{child.name}" if child.name.startswith(".") else f"a-{child.name}"
            )
            if child.is_dir:
                if _should_ignore_dir(
                    child,
                    supports_dir_match=supports_dir_match,
                    match_non_persistent_paths=match_non_persistent_paths,
                ):
                    continue
                items.append(
                    CompletionItem(
                        f"{child.path}/",
                        label_details=CompletionItemLabelDetails(
                            description=child.path,
                        ),
                        insert_text=path_escaper(f"{child.path}/"),
                        filter_text=f"{child.path}/",
                        sort_text=sort_text,
                        kind=CompletionItemKind.Folder,
                    )
                )
            else:
                items.append(
                    CompletionItem(
                        child.path,
                        label_details=CompletionItemLabelDetails(
                            description=child.path,
                        ),
                        insert_text=path_escaper(child.path),
                        filter_text=child.path,
                        sort_text=sort_text,
                        kind=CompletionItemKind.File,
                    )
                )
        return items

    def value_options_for_completer(
        self,
        lint_state: LintState,
        stanza_parts: Sequence[Deb822ParagraphElement],
        value_being_completed: str,
        markdown_kind: MarkupKind,
        *,
        is_completion_for_field: bool = False,
    ) -> Sequence[CompletionItem] | None:
        known_values = self.known_values
        if self.field_value_class == FieldValueClass.DEP5_FILE_LIST:
            if is_completion_for_field:
                return None
            return self._complete_files(
                lint_state.source_root,
                value_being_completed,
                is_dep5_file_list=True,
            )

        if known_values is None:
            return None
        if is_completion_for_field and (
            len(known_values) == 1
            or (
                len(known_values) == 2
                and self.warn_if_default
                and self.default_value is not None
            )
        ):
            value = next(
                iter(v for v in self.known_values if v != self.default_value),
                None,
            )
            if value is None:
                return None
            return [CompletionItem(value, insert_text=value)]
        return [
            keyword.as_completion_item(
                lint_state,
                stanza_parts,
                value_being_completed,
                markdown_kind,
            )
            for keyword in known_values.values()
            if keyword.is_keyword_valid_completion_in_stanza(stanza_parts)
            and keyword.is_completion_suggestion
        ]

    def field_omitted_diagnostics(
        self,
        deb822_file: Deb822FileElement,
        representation_field_range: "TERange",
        stanza: Deb822ParagraphElement,
        stanza_position: "TEPosition",
        header_stanza: Deb822FileElement | None,
        lint_state: LintState,
    ) -> None:
        missing_field_severity = self.missing_field_severity
        if missing_field_severity is None:
            return

        if (
            self.inheritable_from_other_stanza
            and header_stanza is not None
            and self.name in header_stanza
        ):
            return

        lint_state.emit_diagnostic(
            representation_field_range,
            f"Stanza is missing field {self.name}",
            missing_field_severity,
            self.missing_field_authority,
        )

    async def field_diagnostics(
        self,
        deb822_file: Deb822FileElement,
        kvpair: Deb822KeyValuePairElement,
        stanza: Deb822ParagraphElement,
        stanza_position: "TEPosition",
        kvpair_range_te: "TERange",
        lint_state: LintState,
        *,
        field_name_typo_reported: bool = False,
    ) -> None:
        field_name_token = kvpair.field_token
        field_name_range_te = kvpair.field_token.range_in_parent().relative_to(
            kvpair_range_te.start_pos
        )
        field_name = field_name_token.text
        # The `self.name` attribute is the canonical name whereas `field_name` is the name used.
        # This distinction is important for `d/control` where `X[CBS]-` prefixes might be used
        # in one but not the other.
        field_value = stanza[field_name]
        self._diagnostics_for_field_name(
            kvpair_range_te,
            field_name_token,
            field_name_range_te,
            field_name_typo_reported,
            lint_state,
        )
        if self.custom_field_check is not None:
            self.custom_field_check(
                self,
                deb822_file,
                kvpair,
                kvpair_range_te,
                field_name_range_te,
                stanza,
                stanza_position,
                lint_state,
            )
        self._dep5_file_list_diagnostics(kvpair, kvpair_range_te.start_pos, lint_state)
        if self.spellcheck_value:
            words = kvpair.interpret_as(LIST_SPACE_SEPARATED_INTERPRETATION)
            spell_checker = lint_state.spellchecker()
            value_position = kvpair.value_element.position_in_parent().relative_to(
                kvpair_range_te.start_pos
            )
            async for word_ref in lint_state.slow_iter(
                words.iter_value_references(), yield_every=25
            ):
                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_size = TERange(
                        START_POSITION,
                        TEPosition(0, endpos - pos),
                    )
                    lint_state.emit_diagnostic(
                        TERange.from_position_and_size(word_pos_te, word_size),
                        f'Spelling "{word}"',
                        "spelling",
                        "debputy",
                        quickfixes=[
                            propose_correct_text_quick_fix(c) for c in corrections
                        ],
                        enable_non_interactive_auto_fix=False,
                    )
        else:
            self._known_value_diagnostics(
                kvpair,
                kvpair_range_te.start_pos,
                lint_state,
            )

        if self.warn_if_default and field_value == self.default_value:
            lint_state.emit_diagnostic(
                kvpair_range_te,
                f'The field "{field_name}" is redundant as it is set to the default value and the field'
                " should only be used in exceptional cases.",
                "warning",
                "debputy",
            )

    def _diagnostics_for_field_name(
        self,
        kvpair_range: "TERange",
        token: Deb822FieldNameToken,
        token_range: "TERange",
        typo_detected: bool,
        lint_state: LintState,
    ) -> None:
        field_name = token.text
        # Defeat the case-insensitivity from python-debian
        field_name_cased = str(field_name)
        if self.deprecated_with_no_replacement:
            lint_state.emit_diagnostic(
                kvpair_range,
                f'"{field_name_cased}" is deprecated and no longer used',
                "warning",
                "debputy",
                quickfixes=[propose_remove_range_quick_fix()],
                tags=[DiagnosticTag.Deprecated],
            )
        elif self.replaced_by is not None:
            lint_state.emit_diagnostic(
                token_range,
                f'"{field_name_cased}" has been replaced by "{self.replaced_by}"',
                "warning",
                "debputy",
                tags=[DiagnosticTag.Deprecated],
                quickfixes=[propose_correct_text_quick_fix(self.replaced_by)],
            )

        if not typo_detected and field_name_cased != self.name:
            lint_state.emit_diagnostic(
                token_range,
                f'Non-canonical spelling of "{self.name}"',
                "pedantic",
                self.unknown_value_authority,
                quickfixes=[propose_correct_text_quick_fix(self.name)],
            )

    def _dep5_file_list_diagnostics(
        self,
        kvpair: Deb822KeyValuePairElement,
        kvpair_position: "TEPosition",
        lint_state: LintState,
    ) -> None:
        source_root = lint_state.source_root
        if (
            self.field_value_class != FieldValueClass.DEP5_FILE_LIST
            or source_root is None
        ):
            return
        interpreter = self.field_value_class.interpreter()
        values = kvpair.interpret_as(interpreter)
        value_off = kvpair.value_element.position_in_parent().relative_to(
            kvpair_position
        )

        assert interpreter is not None

        for token in values.iter_parts():
            if token.is_whitespace:
                continue
            text = token.convert_to_text()
            if "?" in text or "*" in text:
                # TODO: We should validate these as well
                continue
            matched_path, missing_part = source_root.attempt_lookup(text)
            # It is common practice to delete "dirty" files during clean. This causes files listed
            # in `debian/copyright` to go missing and as a consequence, we do not validate whether
            # they are present (that would require us to check the `.orig.tar`, which we could but
            # do not have the infrastructure for).
            if not missing_part and matched_path.is_dir and self.name == "Files":
                path_range_te = token.range_in_parent().relative_to(value_off)
                lint_state.emit_diagnostic(
                    path_range_te,
                    "Directories cannot be a match. Use `dir/*` to match everything in it",
                    "warning",
                    self.unknown_value_authority,
                    quickfixes=[
                        propose_correct_text_quick_fix(f"{matched_path.path}/*")
                    ],
                )

    def _known_value_diagnostics(
        self,
        kvpair: Deb822KeyValuePairElement,
        kvpair_position: "TEPosition",
        lint_state: LintState,
    ) -> None:
        unknown_value_severity = self.unknown_value_severity
        interpreter = self.field_value_class.interpreter()
        if interpreter is None:
            return
        try:
            values = kvpair.interpret_as(interpreter)
        except ValueError:
            value_range = kvpair.value_element.range_in_parent().relative_to(
                kvpair_position
            )
            lint_state.emit_diagnostic(
                value_range,
                "Error while parsing field (diagnostics related to this field may be incomplete)",
                "pedantic",
                "debputy",
            )
            return
        value_off = kvpair.value_element.position_in_parent().relative_to(
            kvpair_position
        )

        last_token_non_ws_sep_token: TE | None = None
        for token in values.iter_parts():
            if token.is_whitespace:
                continue
            if not token.is_separator:
                last_token_non_ws_sep_token = None
                continue
            if last_token_non_ws_sep_token is not None:
                sep_range_te = token.range_in_parent().relative_to(value_off)
                lint_state.emit_diagnostic(
                    sep_range_te,
                    "Duplicate separator",
                    "error",
                    self.unknown_value_authority,
                )
            last_token_non_ws_sep_token = token

        allowed_values = self.known_values
        if not allowed_values:
            return

        first_value = None
        first_exclusive_value_ref = None
        first_exclusive_value = None
        has_emitted_for_exclusive = False

        for value_ref in values.iter_value_references():
            value = value_ref.value
            if (
                first_value is not None
                and self.field_value_class == FieldValueClass.SINGLE_VALUE
            ):
                value_loc = value_ref.locatable
                range_position_te = value_loc.range_in_parent().relative_to(value_off)
                lint_state.emit_diagnostic(
                    range_position_te,
                    f"The field {self.name} can only have exactly one value.",
                    "error",
                    self.unknown_value_authority,
                )
                # TODO: Add quickfix if the value is also invalid
                continue

            if first_exclusive_value_ref is not None and not has_emitted_for_exclusive:
                assert first_exclusive_value is not None
                value_loc = first_exclusive_value_ref.locatable
                value_range_te = value_loc.range_in_parent().relative_to(value_off)
                lint_state.emit_diagnostic(
                    value_range_te,
                    f'The value "{first_exclusive_value}" cannot be used with other values.',
                    "error",
                    self.unknown_value_authority,
                )

            known_value, unknown_value_message, unknown_severity, typo_fix_data = (
                _unknown_value_check(
                    self.name,
                    value,
                    self.known_values,
                    unknown_value_severity,
                )
            )
            value_loc = value_ref.locatable
            value_range = value_loc.range_in_parent().relative_to(value_off)

            if known_value and known_value.is_exclusive:
                first_exclusive_value = known_value.value  # In case of typos.
                first_exclusive_value_ref = value_ref
                if first_value is not None:
                    has_emitted_for_exclusive = True
                    lint_state.emit_diagnostic(
                        value_range,
                        f'The value "{known_value.value}" cannot be used with other values.',
                        "error",
                        self.unknown_value_authority,
                    )

            if first_value is None:
                first_value = value

            if unknown_value_message is not None:
                assert unknown_severity is not None
                lint_state.emit_diagnostic(
                    value_range,
                    unknown_value_message,
                    unknown_severity,
                    self.unknown_value_authority,
                    quickfixes=typo_fix_data,
                )

            if known_value is not None and known_value.is_deprecated:
                replacement = known_value.replaced_by
                if replacement is not None:
                    obsolete_value_message = (
                        f'The value "{value}" has been replaced by "{replacement}"'
                    )
                    obsolete_fix_data = [propose_correct_text_quick_fix(replacement)]
                else:
                    obsolete_value_message = (
                        f'The value "{value}" is obsolete without a single replacement'
                    )
                    obsolete_fix_data = None
                lint_state.emit_diagnostic(
                    value_range,
                    obsolete_value_message,
                    "warning",
                    "debputy",
                    quickfixes=obsolete_fix_data,
                )

    def _reformat_field_name(
        self,
        effective_preference: "EffectiveFormattingPreference",
        stanza_range: TERange,
        kvpair: Deb822KeyValuePairElement,
        position_codec: LintCapablePositionCodec,
        lines: list[str],
    ) -> Iterable[TextEdit]:
        if not effective_preference.deb822_auto_canonical_size_field_names:
            return
        # The `str(kvpair.field_name)` is to avoid the "magic" from `python3-debian`'s Deb822 keys.
        if str(kvpair.field_name) == self.name:
            return

        field_name_range_te = kvpair.field_token.range_in_parent().relative_to(
            kvpair.range_in_parent().relative_to(stanza_range.start_pos).start_pos
        )

        edit_range = position_codec.range_to_client_units(
            lines,
            Range(
                Position(
                    field_name_range_te.start_pos.line_position,
                    field_name_range_te.start_pos.cursor_position,
                ),
                Position(
                    field_name_range_te.start_pos.line_position,
                    field_name_range_te.end_pos.cursor_position,
                ),
            ),
        )
        yield TextEdit(
            edit_range,
            self.name,
        )

    def reformat_field(
        self,
        effective_preference: "EffectiveFormattingPreference",
        stanza_range: TERange,
        kvpair: Deb822KeyValuePairElement,
        formatter: FormatterCallback,
        position_codec: LintCapablePositionCodec,
        lines: list[str],
    ) -> Iterable[TextEdit]:
        kvpair_range = kvpair.range_in_parent().relative_to(stanza_range.start_pos)
        yield from self._reformat_field_name(
            effective_preference,
            stanza_range,
            kvpair,
            position_codec,
            lines,
        )
        return trim_end_of_line_whitespace(
            position_codec,
            lines,
            line_range=range(
                kvpair_range.start_pos.line_position,
                kvpair_range.end_pos.line_position,
            ),
        )

    def replace(self, **changes: Any) -> "Self":
        return dataclasses.replace(self, **changes)


@dataclasses.dataclass(slots=True)
class DctrlLikeKnownField(Deb822KnownField):

    def reformat_field(
        self,
        effective_preference: "EffectiveFormattingPreference",
        stanza_range: TERange,
        kvpair: Deb822KeyValuePairElement,
        formatter: FormatterCallback,
        position_codec: LintCapablePositionCodec,
        lines: list[str],
    ) -> Iterable[TextEdit]:
        interpretation = self.field_value_class.interpreter()
        if (
            not effective_preference.deb822_normalize_field_content
            or interpretation is None
        ):
            yield from super(DctrlLikeKnownField, self).reformat_field(
                effective_preference,
                stanza_range,
                kvpair,
                formatter,
                position_codec,
                lines,
            )
            return
        if not self.reformattable_field:
            yield from super(DctrlLikeKnownField, self).reformat_field(
                effective_preference,
                stanza_range,
                kvpair,
                formatter,
                position_codec,
                lines,
            )
            return

        # Preserve the name fixes from the super call.
        yield from self._reformat_field_name(
            effective_preference,
            stanza_range,
            kvpair,
            position_codec,
            lines,
        )

        seen: set[str] = set()
        old_kvpair_range = kvpair.range_in_parent()
        sort = self.is_sortable_field

        # Avoid the context manager as we do not want to perform the change (it would contaminate future ranges)
        field_content = kvpair.interpret_as(interpretation)
        old_value = field_content.convert_to_text(with_field_name=False)
        for package_ref in field_content.iter_value_references():
            value = package_ref.value
            value_range = package_ref.locatable.range_in_parent().relative_to(
                stanza_range.start_pos
            )
            sublines = lines[
                value_range.start_pos.line_position : value_range.end_pos.line_position
            ]

            # debputy#112: Avoid truncating "inline comments"
            if any(line.startswith("#") for line in sublines):
                return
            if self.is_relationship_field:
                new_value = " | ".join(x.strip() for x in value.split("|"))
            else:
                new_value = value
            if not sort or new_value not in seen:
                if new_value != value:
                    package_ref.value = new_value
                seen.add(new_value)
            else:
                package_ref.remove()
            if sort:
                field_content.sort(key=_sort_packages_key)
            field_content.value_formatter(formatter)
            field_content.reformat_when_finished()

        new_value = field_content.convert_to_text(with_field_name=False)
        if new_value != old_value:
            value_range = kvpair.value_element.range_in_parent().relative_to(
                old_kvpair_range.start_pos
            )
            range_server_units = te_range_to_lsp(
                value_range.relative_to(stanza_range.start_pos)
            )
            yield TextEdit(
                position_codec.range_to_client_units(lines, range_server_units),
                new_value,
            )

    @property
    def reformattable_field(self) -> bool:
        return self.is_relationship_field or self.is_sortable_field

    @property
    def is_relationship_field(self) -> bool:
        return False

    @property
    def is_sortable_field(self) -> bool:
        return self.is_relationship_field


@dataclasses.dataclass(slots=True)
class DTestsCtrlKnownField(DctrlLikeKnownField):
    @property
    def is_relationship_field(self) -> bool:
        return self.name == "Depends"

    @property
    def is_sortable_field(self) -> bool:
        return self.is_relationship_field or self.name in (
            "Features",
            "Restrictions",
            "Tests",
        )


@dataclasses.dataclass(slots=True)
class DctrlKnownField(DctrlLikeKnownField):

    def field_omitted_diagnostics(
        self,
        deb822_file: Deb822FileElement,
        representation_field_range: "TERange",
        stanza: Deb822ParagraphElement,
        stanza_position: "TEPosition",
        header_stanza: Deb822FileElement | None,
        lint_state: LintState,
    ) -> None:
        missing_field_severity = self.missing_field_severity
        if missing_field_severity is None:
            return

        if (
            self.inheritable_from_other_stanza
            and header_stanza is not None
            and self.name in header_stanza
        ):
            return

        if self.name == "Standards-Version":
            stanzas = list(deb822_file)[1:]
            if all(s.get("Package-Type") == "udeb" for s in stanzas):
                return

        lint_state.emit_diagnostic(
            representation_field_range,
            f"Stanza is missing field {self.name}",
            missing_field_severity,
            self.missing_field_authority,
        )

    def reformat_field(
        self,
        effective_preference: "EffectiveFormattingPreference",
        stanza_range: TERange,
        kvpair: Deb822KeyValuePairElement,
        formatter: FormatterCallback,
        position_codec: LintCapablePositionCodec,
        lines: list[str],
    ) -> Iterable[TextEdit]:
        if (
            self.name == "Architecture"
            and effective_preference.deb822_normalize_field_content
        ):
            interpretation = self.field_value_class.interpreter()
            assert interpretation is not None
            interpreted = kvpair.interpret_as(interpretation)
            archs = list(interpreted)
            # Sort, with wildcard entries (such as linux-any) first:
            archs = sorted(archs, key=lambda x: ("any" not in x, x))
            new_value = f" {' '.join(archs)}\n"
            reformat_edits = list(
                self._reformat_field_name(
                    effective_preference,
                    stanza_range,
                    kvpair,
                    position_codec,
                    lines,
                )
            )
            if new_value != interpreted.convert_to_text(with_field_name=False):
                value_range = kvpair.value_element.range_in_parent().relative_to(
                    kvpair.range_in_parent().start_pos
                )
                kvpair_range = te_range_to_lsp(
                    value_range.relative_to(stanza_range.start_pos)
                )
                reformat_edits.append(
                    TextEdit(
                        position_codec.range_to_client_units(lines, kvpair_range),
                        new_value,
                    )
                )
            return reformat_edits

        return super(DctrlKnownField, self).reformat_field(
            effective_preference,
            stanza_range,
            kvpair,
            formatter,
            position_codec,
            lines,
        )

    @property
    def is_relationship_field(self) -> bool:
        name_lc = self.name.lower()
        return (
            name_lc in all_package_relationship_fields()
            or name_lc in all_source_relationship_fields()
        )

    @property
    def reformattable_field(self) -> bool:
        return self.is_relationship_field or self.name == "Uploaders"


@dataclasses.dataclass(slots=True)
class DctrlRelationshipKnownField(DctrlKnownField):
    allowed_version_operators: frozenset[str] = frozenset()
    supports_or_relation: bool = True

    @property
    def is_relationship_field(self) -> bool:
        return True


SOURCE_FIELDS = _fields(
    DctrlKnownField(
        "Source",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_combined_custom_field_check(
            _each_value_match_regex_validation(PKGNAME_REGEX),
            _has_packaging_expected_file(
                "copyright",
                "No copyright file (package license)",
                severity="warning",
            ),
            _has_packaging_expected_file(
                "changelog",
                "No Debian changelog file",
                severity="error",
            ),
            _has_build_instructions,
        ),
    ),
    DctrlKnownField(
        "Standards-Version",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_sv_field_validation,
    ),
    DctrlKnownField(
        "Section",
        FieldValueClass.SINGLE_VALUE,
        known_values=ALL_SECTIONS,
    ),
    DctrlKnownField(
        "Priority",
        FieldValueClass.SINGLE_VALUE,
    ),
    DctrlKnownField(
        "Maintainer",
        FieldValueClass.COMMA_SEPARATED_EMAIL_LIST,
        custom_field_check=_combined_custom_field_check(
            _maintainer_field_validator,
            _canonical_maintainer_name,
        ),
    ),
    DctrlKnownField(
        "Uploaders",
        FieldValueClass.COMMA_SEPARATED_EMAIL_LIST,
        custom_field_check=_canonical_maintainer_name,
    ),
    DctrlRelationshipKnownField(
        "Build-Depends",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlRelationshipKnownField(
        "Build-Depends-Arch",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlRelationshipKnownField(
        "Build-Depends-Indep",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlRelationshipKnownField(
        "Build-Conflicts",
        FieldValueClass.COMMA_SEPARATED_LIST,
        supports_or_relation=False,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlRelationshipKnownField(
        "Build-Conflicts-Arch",
        FieldValueClass.COMMA_SEPARATED_LIST,
        supports_or_relation=False,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlRelationshipKnownField(
        "Build-Conflicts-Indep",
        FieldValueClass.COMMA_SEPARATED_LIST,
        supports_or_relation=False,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlKnownField(
        "Rules-Requires-Root",
        FieldValueClass.SPACE_SEPARATED_LIST,
        custom_field_check=_rrr_build_driver_mismatch,
    ),
    DctrlKnownField(
        "X-Style",
        FieldValueClass.SINGLE_VALUE,
        known_values=ALL_PUBLIC_NAMED_STYLES_AS_KEYWORDS,
    ),
    DctrlKnownField(
        "Homepage",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_validate_homepage_field,
    ),
)


BINARY_FIELDS = _fields(
    DctrlKnownField(
        "Package",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX),
    ),
    DctrlKnownField(
        "Architecture",
        FieldValueClass.SPACE_SEPARATED_LIST,
        # FIXME: Specialize validation for architecture ("!foo" is not a "typo" and should have a better warning)
        known_values=allowed_values(dpkg_arch_and_wildcards()),
    ),
    DctrlKnownField(
        "Pre-Depends",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlKnownField(
        "Depends",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlKnownField(
        "Recommends",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlKnownField(
        "Suggests",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlKnownField(
        "Enhances",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlRelationshipKnownField(
        "Provides",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
        supports_or_relation=False,
        allowed_version_operators=frozenset(["="]),
    ),
    DctrlRelationshipKnownField(
        "Conflicts",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
        supports_or_relation=False,
    ),
    DctrlRelationshipKnownField(
        "Breaks",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
        supports_or_relation=False,
    ),
    DctrlRelationshipKnownField(
        "Replaces",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_dctrl_validate_dep,
    ),
    DctrlKnownField(
        "Build-Profiles",
        FieldValueClass.BUILD_PROFILES_LIST,
    ),
    DctrlKnownField(
        "Section",
        FieldValueClass.SINGLE_VALUE,
        known_values=ALL_SECTIONS,
    ),
    DctrlRelationshipKnownField(
        "Built-Using",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_arch_not_all_only_field_validation,
        can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
        supports_or_relation=False,
        allowed_version_operators=frozenset(["="]),
    ),
    DctrlRelationshipKnownField(
        "Static-Built-Using",
        FieldValueClass.COMMA_SEPARATED_LIST,
        custom_field_check=_arch_not_all_only_field_validation,
        can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
        supports_or_relation=False,
        allowed_version_operators=frozenset(["="]),
    ),
    DctrlKnownField(
        "Multi-Arch",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_dctrl_ma_field_validation,
        known_values=allowed_values(
            (
                Keyword(
                    "same",
                    can_complete_keyword_in_stanza=_complete_only_in_arch_dep_pkgs,
                ),
            ),
        ),
    ),
    DctrlKnownField(
        "XB-Installer-Menu-Item",
        FieldValueClass.SINGLE_VALUE,
        can_complete_field_in_stanza=_complete_only_for_udeb_pkgs,
        custom_field_check=_combined_custom_field_check(
            _udeb_only_field_validation,
            _each_value_match_regex_validation(re.compile(r"^[1-9]\d{3,4}$")),
        ),
    ),
    DctrlKnownField(
        "X-DH-Build-For-Type",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_arch_not_all_only_field_validation,
        can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
    ),
    DctrlKnownField(
        "X-Doc-Main-Package",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_binary_package_from_same_source,
    ),
    DctrlKnownField(
        "X-Time64-Compat",
        FieldValueClass.SINGLE_VALUE,
        can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
        custom_field_check=_combined_custom_field_check(
            _each_value_match_regex_validation(PKGNAME_REGEX),
            _arch_not_all_only_field_validation,
        ),
    ),
    DctrlKnownField(
        "Description",
        FieldValueClass.FREE_TEXT_FIELD,
        custom_field_check=dctrl_description_validator,
    ),
    DctrlKnownField(
        "XB-Cnf-Visible-Pkgname",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX),
    ),
    DctrlKnownField(
        "Homepage",
        FieldValueClass.SINGLE_VALUE,
        show_as_inherited=False,
        custom_field_check=_validate_homepage_field,
    ),
)
_DEP5_HEADER_FIELDS = _fields(
    Deb822KnownField(
        "Format",
        FieldValueClass.SINGLE_VALUE,
        custom_field_check=_use_https_instead_of_http,
    ),
)
_DEP5_FILES_FIELDS = _fields(
    Deb822KnownField(
        "Files",
        FieldValueClass.DEP5_FILE_LIST,
        custom_field_check=_dep5_files_check,
    ),
)
_DEP5_LICENSE_FIELDS = _fields(
    Deb822KnownField(
        "License",
        FieldValueClass.FREE_TEXT_FIELD,
    ),
)

_DTESTSCTRL_FIELDS = _fields(
    DTestsCtrlKnownField(
        "Architecture",
        FieldValueClass.SPACE_SEPARATED_LIST,
        # FIXME: Specialize validation for architecture ("!fou" to "foo" would be bad)
        known_values=allowed_values(dpkg_arch_and_wildcards(allow_negations=True)),
    ),
)
_DWATCH_HEADER_FIELDS = _fields()
_DWATCH_TEMPLATE_FIELDS = _fields()
_DWATCH_SOURCE_FIELDS = _fields()


@dataclasses.dataclass(slots=True)
class StanzaMetadata(Mapping[str, F], Generic[F], ABC):
    stanza_type_name: str
    stanza_fields: Mapping[str, F]
    is_substvars_allowed_in_stanza: bool

    async def stanza_diagnostics(
        self,
        deb822_file: Deb822FileElement,
        stanza: Deb822ParagraphElement,
        stanza_position_in_file: "TEPosition",
        lint_state: LintState,
        *,
        inherit_from_stanza: Deb822ParagraphElement | None = None,
        confusable_with_stanza_name: str | None = None,
        confusable_with_stanza_metadata: Optional["StanzaMetadata[F]"] = None,
    ) -> None:
        if (confusable_with_stanza_name is None) ^ (
            confusable_with_stanza_metadata is None
        ):
            raise ValueError(
                "confusable_with_stanza_name and confusable_with_stanza_metadata must be used together"
            )
        _, representation_field_range = self.stanza_representation(
            stanza,
            stanza_position_in_file,
        )
        known_fields = self.stanza_fields
        self.omitted_field_diagnostics(
            lint_state,
            deb822_file,
            stanza,
            stanza_position_in_file,
            inherit_from_stanza=inherit_from_stanza,
            representation_field_range=representation_field_range,
        )
        seen_fields: dict[str, tuple[str, str, "TERange", list[Range], set[str]]] = {}

        async for kvpair_range, kvpair in lint_state.slow_iter(
            with_range_in_continuous_parts(
                stanza.iter_parts(),
                start_relative_to=stanza_position_in_file,
            ),
            yield_every=1,
        ):
            if not isinstance(kvpair, Deb822KeyValuePairElement):
                continue
            field_name_token = kvpair.field_token
            field_name = field_name_token.text
            field_name_lc = field_name.lower()
            # Defeat any tricks from `python-debian` from here on out
            field_name = str(field_name)
            normalized_field_name_lc = self.normalize_field_name(field_name_lc)
            known_field = known_fields.get(normalized_field_name_lc)
            field_value = stanza[field_name]
            kvpair_range_te = kvpair.range_in_parent().relative_to(
                stanza_position_in_file
            )
            field_range = kvpair.field_token.range_in_parent().relative_to(
                kvpair_range_te.start_pos
            )
            field_position_te = field_range.start_pos
            field_name_typo_detected = False
            dup_field_key = (
                known_field.name
                if known_field is not None
                else normalized_field_name_lc
            )
            existing_field_range = seen_fields.get(dup_field_key)
            if existing_field_range is not None:
                existing_field_range[3].append(field_range)
                existing_field_range[4].add(field_name)
            else:
                normalized_field_name = self.normalize_field_name(field_name)
                seen_fields[dup_field_key] = (
                    known_field.name if known_field else field_name,
                    normalized_field_name,
                    field_range,
                    [],
                    {field_name},
                )

            if known_field is None:
                candidates = detect_possible_typo(
                    normalized_field_name_lc, known_fields
                )
                if candidates:
                    known_field = known_fields[candidates[0]]
                    field_range = TERange.from_position_and_size(
                        field_position_te, kvpair.field_token.size()
                    )
                    field_name_typo_detected = True
                    lint_state.emit_diagnostic(
                        field_range,
                        f'The "{field_name}" looks like a typo of "{known_field.name}".',
                        "warning",
                        "debputy",
                        quickfixes=[
                            propose_correct_text_quick_fix(known_fields[m].name)
                            for m in candidates
                        ],
                    )
            if field_value.strip() == "":
                lint_state.emit_diagnostic(
                    field_range,
                    f"The {field_name} has no value. Either provide a value or remove it.",
                    "error",
                    "Policy 5.1",
                )
                continue
            if known_field is None:
                known_else_where = confusable_with_stanza_metadata.stanza_fields.get(
                    normalized_field_name_lc
                )
                if known_else_where is not None:
                    lint_state.emit_diagnostic(
                        field_range,
                        f"The {kvpair.field_name} is defined for use in the"
                        f' "{confusable_with_stanza_name}" stanza. Please move it to the right place or remove it',
                        "error",
                        known_else_where.missing_field_authority,
                    )
                continue
            await known_field.field_diagnostics(
                deb822_file,
                kvpair,
                stanza,
                stanza_position_in_file,
                kvpair_range_te,
                lint_state,
                field_name_typo_reported=field_name_typo_detected,
            )

            inherit_value = (
                inherit_from_stanza.get(field_name) if inherit_from_stanza else None
            )

            if (
                known_field.inheritable_from_other_stanza
                and inherit_value is not None
                and field_value == inherit_value
            ):
                quick_fix = propose_remove_range_quick_fix(
                    proposed_title="Remove redundant definition"
                )
                lint_state.emit_diagnostic(
                    kvpair_range_te,
                    f"The field {field_name} duplicates the value from the Source stanza.",
                    "informational",
                    "debputy",
                    quickfixes=[quick_fix],
                )
        for (
            field_name,
            normalized_field_name,
            field_range,
            duplicates,
            used_fields,
        ) in seen_fields.values():
            if not duplicates:
                continue
            if len(used_fields) != 1 or field_name not in used_fields:
                via_aliases_msg = " (via aliases)"
            else:
                via_aliases_msg = ""
            for dup_range in duplicates:
                lint_state.emit_diagnostic(
                    dup_range,
                    f'The field "{field_name}"{via_aliases_msg} was used multiple times in this stanza.'
                    " Please ensure the field is only used once per stanza.",
                    "error",
                    "Policy 5.1",
                    related_information=[
                        lint_state.related_diagnostic_information(
                            field_range,
                            message=f"First definition of {field_name}",
                        ),
                    ],
                )

    def __getitem__(self, key: str) -> F:
        key_lc = key.lower()
        key_norm = normalize_dctrl_field_name(key_lc)
        return self.stanza_fields[key_norm]

    def __len__(self) -> int:
        return len(self.stanza_fields)

    def __iter__(self) -> Iterator[str]:
        return iter(self.stanza_fields.keys())

    def omitted_field_diagnostics(
        self,
        lint_state: LintState,
        deb822_file: Deb822FileElement,
        stanza: Deb822ParagraphElement,
        stanza_position: "TEPosition",
        *,
        inherit_from_stanza: Deb822ParagraphElement | None = None,
        representation_field_range: Range | None = None,
    ) -> None:
        if representation_field_range is None:
            _, representation_field_range = self.stanza_representation(
                stanza,
                stanza_position,
            )
        for known_field in self.stanza_fields.values():
            if known_field.name in stanza:
                continue

            known_field.field_omitted_diagnostics(
                deb822_file,
                representation_field_range,
                stanza,
                stanza_position,
                inherit_from_stanza,
                lint_state,
            )

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

    def normalize_field_name(self, field_name: str) -> str:
        return field_name

    def stanza_representation(
        self,
        stanza: Deb822ParagraphElement,
        stanza_position: TEPosition,
    ) -> tuple[Deb822KeyValuePairElement, TERange]:
        representation_field = self._paragraph_representation_field(stanza)
        representation_field_range = representation_field.range_in_parent().relative_to(
            stanza_position
        )
        return representation_field, representation_field_range

    def reformat_stanza(
        self,
        effective_preference: "EffectiveFormattingPreference",
        stanza: Deb822ParagraphElement,
        stanza_range: TERange,
        formatter: FormatterCallback,
        position_codec: LintCapablePositionCodec,
        lines: list[str],
    ) -> Iterable[TextEdit]:
        for field_name in stanza:
            known_field = self.stanza_fields.get(field_name.lower())
            if known_field is None:
                continue
            kvpair = stanza.get_kvpair_element(field_name)
            yield from known_field.reformat_field(
                effective_preference,
                stanza_range,
                kvpair,
                formatter,
                position_codec,
                lines,
            )


@dataclasses.dataclass(slots=True)
class Dep5StanzaMetadata(StanzaMetadata[Deb822KnownField]):
    pass


@dataclasses.dataclass(slots=True)
class DctrlStanzaMetadata(StanzaMetadata[DctrlKnownField]):

    def normalize_field_name(self, field_name: str) -> str:
        return normalize_dctrl_field_name(field_name)


@dataclasses.dataclass(slots=True)
class DTestsCtrlStanzaMetadata(StanzaMetadata[DTestsCtrlKnownField]):

    def omitted_field_diagnostics(
        self,
        lint_state: LintState,
        deb822_file: Deb822FileElement,
        stanza: Deb822ParagraphElement,
        stanza_position: "TEPosition",
        *,
        inherit_from_stanza: Deb822ParagraphElement | None = None,
        representation_field_range: Range | None = None,
    ) -> None:
        if representation_field_range is None:
            _, representation_field_range = self.stanza_representation(
                stanza,
                stanza_position,
            )
        auth_ref = self.stanza_fields["tests"].missing_field_authority
        if "Tests" not in stanza and "Test-Command" not in stanza:
            lint_state.emit_diagnostic(
                representation_field_range,
                'Stanza must have either a "Tests" or a "Test-Command" field',
                "error",
                # TODO: Better authority_reference
                auth_ref,
            )
        if "Tests" in stanza and "Test-Command" in stanza:
            lint_state.emit_diagnostic(
                representation_field_range,
                'Stanza cannot have both a "Tests" and a "Test-Command" field',
                "error",
                # TODO: Better authority_reference
                auth_ref,
            )

        # Note that since we do not use the field names for stanza classification, we
        # always do the super call.
        super(DTestsCtrlStanzaMetadata, self).omitted_field_diagnostics(
            lint_state,
            deb822_file,
            stanza,
            stanza_position,
            representation_field_range=representation_field_range,
            inherit_from_stanza=inherit_from_stanza,
        )


@dataclasses.dataclass(slots=True)
class DebianWatchStanzaMetadata(StanzaMetadata[Deb822KnownField]):

    def omitted_field_diagnostics(
        self,
        lint_state: LintState,
        deb822_file: Deb822FileElement,
        stanza: Deb822ParagraphElement,
        stanza_position: "TEPosition",
        *,
        inherit_from_stanza: Deb822ParagraphElement | None = None,
        representation_field_range: Range | None = None,
    ) -> None:
        if representation_field_range is None:
            _, representation_field_range = self.stanza_representation(
                stanza,
                stanza_position,
            )

        if (
            self.stanza_type_name != "Header"
            and "Source" not in stanza
            and "Template" not in stanza
        ):
            lint_state.emit_diagnostic(
                representation_field_range,
                'Stanza must have either a "Source" or a "Template" field',
                "error",
                # TODO: Better authority_reference
                "debputy",
            )
            # The required fields depends on which stanza it is. Therefore, we omit the super
            # call until this error is resolved.
            return

        super(DebianWatchStanzaMetadata, self).omitted_field_diagnostics(
            lint_state,
            deb822_file,
            stanza,
            stanza_position,
            representation_field_range=representation_field_range,
            inherit_from_stanza=inherit_from_stanza,
        )


def lsp_reference_data_dir() -> str:
    return os.path.join(
        os.path.dirname(__file__),
        "data",
    )


class Deb822FileMetadata(Generic[S, F]):

    def __init__(self) -> None:
        self._is_initialized = False
        self._data: Deb822ReferenceData | None = None

    @property
    def reference_data_basename(self) -> str:
        raise NotImplementedError

    def _new_field(
        self,
        name: str,
        field_value_type: FieldValueClass,
    ) -> F:
        raise NotImplementedError

    def _reference_data(self) -> Deb822ReferenceData:
        ref = self._data
        if ref is not None:
            return ref

        p = importlib.resources.files(deb822_ref_data_dir.__name__).joinpath(
            self.reference_data_basename
        )

        with p.open("r", encoding="utf-8") as fd:
            raw = MANIFEST_YAML.load(fd)

        attr_path = AttributePath.root_path(p)
        try:
            ref = DEB822_REFERENCE_DATA_PARSER.parse_input(raw, attr_path)
        except ManifestParseException as e:
            raise ValueError(
                f"Internal error: Could not parse reference data [{self.reference_data_basename}]: {e.message}"
            ) from e
        self._data = ref
        return ref

    @property
    def is_initialized(self) -> bool:
        return self._is_initialized

    def ensure_initialized(self) -> None:
        if self.is_initialized:
            return
        # Enables us to use __getitem__
        self._is_initialized = True
        ref_data = self._reference_data()
        ref_defs = ref_data.get("definitions")
        variables = {}
        ref_variables = ref_defs.get("variables", []) if ref_defs else []
        for ref_variable in ref_variables:
            name = ref_variable["name"]
            fallback = ref_variable["fallback"]
            variables[name] = fallback

        def _resolve_doc(template: str | None) -> str | None:
            if template is None:
                return None
            try:
                return template.format(**variables)
            except ValueError as e:
                template_escaped = template.replace("\n", "\\r")
                _error(f"Bad template: {template_escaped}: {e}")

        for ref_stanza_type in ref_data["stanza_types"]:
            stanza_name = ref_stanza_type["stanza_name"]
            stanza = self[stanza_name]
            stanza_fields = dict(stanza.stanza_fields)
            stanza.stanza_fields = stanza_fields
            for ref_field in ref_stanza_type["fields"]:
                _resolve_field(
                    ref_field,
                    stanza_fields,
                    self._new_field,
                    _resolve_doc,
                    f"Stanza:{stanza.stanza_type_name}|Field:{ref_field['canonical_name']}",
                )

    def file_metadata_applies_to_file(
        self,
        deb822_file: Deb822FileElement | None,
    ) -> bool:
        return deb822_file is not None

    def classify_stanza(self, stanza: Deb822ParagraphElement, stanza_idx: int) -> S:
        return self.guess_stanza_classification_by_idx(stanza_idx)

    def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S:
        raise NotImplementedError

    def stanza_types(self) -> Iterable[S]:
        raise NotImplementedError

    def __getitem__(self, item: str) -> S:
        raise NotImplementedError

    def get(self, item: str) -> S | None:
        try:
            return self[item]
        except KeyError:
            return None

    def reformat(
        self,
        effective_preference: "EffectiveFormattingPreference",
        deb822_file: Deb822FileElement,
        formatter: FormatterCallback,
        _content: str,
        position_codec: LintCapablePositionCodec,
        lines: list[str],
    ) -> Iterable[TextEdit]:
        stanza_idx = -1
        for token_or_element in deb822_file.iter_parts():
            if isinstance(token_or_element, Deb822ParagraphElement):
                stanza_range = token_or_element.range_in_parent()
                stanza_idx += 1
                stanza_metadata = self.classify_stanza(token_or_element, stanza_idx)
                yield from stanza_metadata.reformat_stanza(
                    effective_preference,
                    token_or_element,
                    stanza_range,
                    formatter,
                    position_codec,
                    lines,
                )
            else:
                token_range = token_or_element.range_in_parent()
                yield from trim_end_of_line_whitespace(
                    position_codec,
                    lines,
                    line_range=range(
                        token_range.start_pos.line_position,
                        token_range.end_pos.line_position,
                    ),
                )


_DCTRL_SOURCE_STANZA = DctrlStanzaMetadata(
    "Source",
    SOURCE_FIELDS,
    is_substvars_allowed_in_stanza=False,
)
_DCTRL_PACKAGE_STANZA = DctrlStanzaMetadata(
    "Package",
    BINARY_FIELDS,
    is_substvars_allowed_in_stanza=True,
)

_DEP5_HEADER_STANZA = Dep5StanzaMetadata(
    "Header",
    _DEP5_HEADER_FIELDS,
    is_substvars_allowed_in_stanza=False,
)
_DEP5_FILES_STANZA = Dep5StanzaMetadata(
    "Files",
    _DEP5_FILES_FIELDS,
    is_substvars_allowed_in_stanza=False,
)
_DEP5_LICENSE_STANZA = Dep5StanzaMetadata(
    "License",
    _DEP5_LICENSE_FIELDS,
    is_substvars_allowed_in_stanza=False,
)

_DTESTSCTRL_STANZA = DTestsCtrlStanzaMetadata(
    "Tests",
    _DTESTSCTRL_FIELDS,
    is_substvars_allowed_in_stanza=False,
)

_WATCH_HEADER_HEADER_STANZA = DebianWatchStanzaMetadata(
    "Header",
    _DWATCH_HEADER_FIELDS,
    is_substvars_allowed_in_stanza=False,
)
_WATCH_SOURCE_STANZA = DebianWatchStanzaMetadata(
    "Source",
    _DWATCH_SOURCE_FIELDS,
    is_substvars_allowed_in_stanza=False,
)


class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata, Deb822KnownField]):

    @property
    def reference_data_basename(self) -> str:
        return "debian_copyright_reference_data.yaml"

    def _new_field(
        self,
        name: str,
        field_value_type: FieldValueClass,
    ) -> F:
        return Deb822KnownField(name, field_value_type)

    def file_metadata_applies_to_file(
        self,
        deb822_file: Deb822FileElement | None,
    ) -> bool:
        if not super().file_metadata_applies_to_file(deb822_file):
            return False
        first_stanza = next(iter(deb822_file), None)
        if first_stanza is None or "Format" not in first_stanza:
            # No parseable stanzas or the first one did not have a Format, which is necessary.
            return False

        for part in deb822_file.iter_parts():
            if part.is_error:
                # Error first, then it might just be a "Format:" in the middle of a free-text file.
                return False
            if part is first_stanza:
                break
        return True

    def classify_stanza(
        self,
        stanza: Deb822ParagraphElement,
        stanza_idx: int,
    ) -> Dep5StanzaMetadata:
        self.ensure_initialized()
        if stanza_idx == 0:
            return _DEP5_HEADER_STANZA
        if stanza_idx > 0:
            if "Files" in stanza:
                return _DEP5_FILES_STANZA
            return _DEP5_LICENSE_STANZA
        raise ValueError("The stanza_idx must be 0 or greater")

    def guess_stanza_classification_by_idx(self, stanza_idx: int) -> Dep5StanzaMetadata:
        self.ensure_initialized()
        if stanza_idx == 0:
            return _DEP5_HEADER_STANZA
        if stanza_idx > 0:
            return _DEP5_FILES_STANZA
        raise ValueError("The stanza_idx must be 0 or greater")

    def stanza_types(self) -> Iterable[Dep5StanzaMetadata]:
        self.ensure_initialized()
        # Order assumption made in the LSP code.
        yield _DEP5_HEADER_STANZA
        yield _DEP5_FILES_STANZA
        yield _DEP5_LICENSE_STANZA

    def __getitem__(self, item: str) -> Dep5StanzaMetadata:
        self.ensure_initialized()
        if item == "Header":
            return _DEP5_HEADER_STANZA
        if item == "Files":
            return _DEP5_FILES_STANZA
        if item == "License":
            return _DEP5_LICENSE_STANZA
        raise KeyError(item)


class DebianWatch5FileMetadata(
    Deb822FileMetadata[DebianWatchStanzaMetadata, Deb822KnownField]
):

    @property
    def reference_data_basename(self) -> str:
        return "debian_watch_reference_data.yaml"

    def _new_field(
        self,
        name: str,
        field_value_type: FieldValueClass,
    ) -> F:
        return Deb822KnownField(name, field_value_type)

    def file_metadata_applies_to_file(
        self, deb822_file: Deb822FileElement | None
    ) -> bool:
        if not super().file_metadata_applies_to_file(deb822_file):
            return False
        first_stanza = next(iter(deb822_file), None)

        if first_stanza is None or "Version" not in first_stanza:
            # No parseable stanzas or the first one did not have a Version field, which is necessary.
            return False

        try:
            if int(first_stanza.get("Version")) < 5:
                return False
        except (ValueError, IndexError, TypeError):
            return False

        for part in deb822_file.iter_parts():
            if part.is_error:
                # Error first, then it might just be a "Version:" in the middle of a free-text file.
                return False
            if part is first_stanza:
                break
        return True

    def classify_stanza(
        self,
        stanza: Deb822ParagraphElement,
        stanza_idx: int,
    ) -> DebianWatchStanzaMetadata:
        self.ensure_initialized()
        if stanza_idx == 0:
            return _WATCH_HEADER_HEADER_STANZA
        if stanza_idx > 0:
            return _WATCH_SOURCE_STANZA
        raise ValueError("The stanza_idx must be 0 or greater")

    def guess_stanza_classification_by_idx(
        self, stanza_idx: int
    ) -> DebianWatchStanzaMetadata:
        self.ensure_initialized()
        if stanza_idx == 0:
            return _WATCH_HEADER_HEADER_STANZA
        if stanza_idx > 0:
            return _WATCH_SOURCE_STANZA
        raise ValueError("The stanza_idx must be 0 or greater")

    def stanza_types(self) -> Iterable[DebianWatchStanzaMetadata]:
        self.ensure_initialized()
        # Order assumption made in the LSP code.
        yield _WATCH_HEADER_HEADER_STANZA
        yield _WATCH_SOURCE_STANZA

    def __getitem__(self, item: str) -> DebianWatchStanzaMetadata:
        self.ensure_initialized()
        if item == "Header":
            return _WATCH_HEADER_HEADER_STANZA
        if item == "Source":
            return _WATCH_SOURCE_STANZA
        raise KeyError(item)


def _resolve_keyword(
    ref_value: StaticValue,
    known_values: dict[str, Keyword],
    resolve_template: Callable[[str | None], str | None],
    translation_context: str,
) -> None:
    value_key = ref_value["value"]
    changes = {
        "translation_context": translation_context,
    }
    try:
        known_value = known_values[value_key]
    except KeyError:
        known_value = Keyword(value_key)
        known_values[value_key] = known_value
    else:
        if known_value.is_alias_of:
            raise ValueError(
                f"The value {known_value.value} has an alias {known_value.is_alias_of} that conflicts with"
                f' {value_key} or the data file used an alias in its `canonical-name` rather than the "true" name'
            )
    value_doc = ref_value.get("documentation")
    if value_doc is not None:
        changes["synopsis"] = value_doc.get("synopsis")
        changes["long_description"] = resolve_template(
            value_doc.get("long_description")
        )
    if is_exclusive := ref_value.get("is_exclusive"):
        changes["is_exclusive"] = is_exclusive
    if (sort_key := ref_value.get("sort_key")) is not None:
        changes["sort_text"] = sort_key
    if (usage_hint := ref_value.get("usage_hint")) is not None:
        changes["usage_hint"] = usage_hint
    if changes:
        known_value = known_value.replace(**changes)
        known_values[value_key] = known_value

    _expand_aliases(
        known_value,
        known_values,
        operator.attrgetter("value"),
        ref_value.get("aliases"),
        "The value `{ALIAS}` is an alias of `{NAME}`.",
    )


def _resolve_field(
    ref_field: Deb822Field,
    stanza_fields: dict[str, F],
    field_constructor: Callable[[str, FieldValueClass], F],
    resolve_template: Callable[[str | None], str | None],
    translation_context: str,
) -> None:
    field_name = ref_field["canonical_name"]
    field_value_type = FieldValueClass.from_key(ref_field["field_value_type"])
    doc = ref_field.get("documentation")
    ref_values = ref_field.get("values", [])
    norm_field_name = normalize_dctrl_field_name(field_name.lower())

    try:
        field = stanza_fields[norm_field_name]
    except KeyError:
        field = field_constructor(
            field_name,
            field_value_type,
        )
        stanza_fields[norm_field_name] = field
    else:
        if field.name != field_name:
            _error(
                f'Error in reference data: Code uses "{field.name}" as canonical name and the data file'
                f" uses {field_name}. Please ensure the data is correctly aligned."
            )
        if field.field_value_class != field_value_type:
            _error(
                f'Error in reference data for field "{field.name}": Code has'
                f" {field.field_value_class.key} and the data file uses {field_value_type.key}"
                f" for field-value-type. Please ensure the data is correctly aligned."
            )
        if field.is_alias_of:
            raise ValueError(
                f"The field {field.name} has an alias {field.is_alias_of} that conflicts with"
                f' {field_name} or the data file used an alias in its `canonical-name` rather than the "true" name'
            )

    if doc is not None:
        field.synopsis = doc.get("synopsis")
        field.long_description = resolve_template(doc.get("long_description"))

    field.default_value = ref_field.get("default_value")
    field.warn_if_default = ref_field.get("warn_if_default", True)
    field.spellcheck_value = ref_field.get("spellcheck_value", False)
    field.deprecated_with_no_replacement = ref_field.get(
        "is_obsolete_without_replacement", False
    )
    field.replaced_by = ref_field.get("replaced_by")
    field.translation_context = translation_context
    field.usage_hint = ref_field.get("usage_hint")
    field.missing_field_severity = ref_field.get("missing_field_severity", None)
    unknown_value_severity = ref_field.get("unknown_value_severity", "error")
    field.unknown_value_severity = (
        None if unknown_value_severity == "none" else unknown_value_severity
    )
    field.unknown_value_authority = ref_field.get("unknown_value_authority", "debputy")
    field.missing_field_authority = ref_field.get("missing_field_authority", "debputy")
    field.is_substvars_disabled_even_if_allowed_by_stanza = not ref_field.get(
        "supports_substvars",
        True,
    )
    field.inheritable_from_other_stanza = ref_field.get(
        "inheritable_from_other_stanza",
        False,
    )

    known_values = field.known_values
    if known_values is None:
        known_values = {}
    else:
        known_values = dict(known_values)

    for ref_value in ref_values:
        _resolve_keyword(ref_value, known_values, resolve_template, translation_context)

    if known_values:
        field.known_values = known_values

    _expand_aliases(
        field,
        stanza_fields,
        operator.attrgetter("name"),
        ref_field.get("aliases"),
        "The field `{ALIAS}` is an alias of `{NAME}`.",
    )


A = TypeVar("A", Keyword, Deb822KnownField)


def _expand_aliases(
    item: A,
    item_container: dict[str, A],
    canonical_name_resolver: Callable[[A], str],
    aliases_ref: list[Alias] | None,
    doc_template: str,
) -> None:
    if aliases_ref is None:
        return
    name = canonical_name_resolver(item)
    assert name is not None, "canonical_name_resolver is not allowed to return None"
    for alias_ref in aliases_ref:
        alias_name = alias_ref["alias"]
        alias_doc = item.long_description
        is_completion_suggestion = alias_ref.get("is_completion_suggestion", False)
        doc_suffix = doc_template.format(NAME=name, ALIAS=alias_name)
        if alias_doc:
            alias_doc += f"\n\n{doc_suffix}"
        else:
            alias_doc = doc_suffix
        alias_field = item.replace(
            long_description=alias_doc,
            is_alias_of=name,
            is_completion_suggestion=is_completion_suggestion,
        )
        alias_key = alias_name.lower()
        if alias_name in item_container:
            existing_name = canonical_name_resolver(item_container[alias_key])
            assert (
                existing_name is not None
            ), "canonical_name_resolver is not allowed to return None"
            raise ValueError(
                f"The value {name} has an alias {alias_name} that conflicts with {existing_name}"
            )
        item_container[alias_key] = alias_field


class DctrlFileMetadata(Deb822FileMetadata[DctrlStanzaMetadata, DctrlKnownField]):

    @property
    def reference_data_basename(self) -> str:
        return "debian_control_reference_data.yaml"

    def _new_field(
        self,
        name: str,
        field_value_type: FieldValueClass,
    ) -> F:
        return DctrlKnownField(name, field_value_type)

    def guess_stanza_classification_by_idx(
        self,
        stanza_idx: int,
    ) -> DctrlStanzaMetadata:
        self.ensure_initialized()
        if stanza_idx == 0:
            return _DCTRL_SOURCE_STANZA
        if stanza_idx > 0:
            return _DCTRL_PACKAGE_STANZA
        raise ValueError("The stanza_idx must be 0 or greater")

    def stanza_types(self) -> Iterable[DctrlStanzaMetadata]:
        self.ensure_initialized()
        # Order assumption made in the LSP code.
        yield _DCTRL_SOURCE_STANZA
        yield _DCTRL_PACKAGE_STANZA

    def __getitem__(self, item: str) -> DctrlStanzaMetadata:
        self.ensure_initialized()
        if item == "Source":
            return _DCTRL_SOURCE_STANZA
        if item == "Package":
            return _DCTRL_PACKAGE_STANZA
        raise KeyError(item)

    def reformat(
        self,
        effective_preference: "EffectiveFormattingPreference",
        deb822_file: Deb822FileElement,
        formatter: FormatterCallback,
        content: str,
        position_codec: LintCapablePositionCodec,
        lines: list[str],
    ) -> Iterable[TextEdit]:
        edits = list(
            super().reformat(
                effective_preference,
                deb822_file,
                formatter,
                content,
                position_codec,
                lines,
            )
        )

        if (
            not effective_preference.deb822_normalize_stanza_order
            or deb822_file.find_first_error_element() is not None
        ):
            return edits
        names = []
        for idx, stanza in enumerate(deb822_file):
            if idx < 2:
                continue
            name = stanza.get("Package")
            if name is None:
                return edits
            names.append(name)

        reordered = sorted(names)
        if names == reordered:
            return edits

        if edits:
            content = apply_text_edits(content, lines, edits)
            lines = content.splitlines(keepends=True)
            deb822_file = parse_deb822_file(
                lines,
                accept_files_with_duplicated_fields=True,
                accept_files_with_error_tokens=True,
            )

        stanzas = list(deb822_file)
        reordered_stanza = stanzas[:2] + sorted(
            stanzas[2:], key=operator.itemgetter("Package")
        )
        bits = []
        stanza_idx = 0
        for token_or_element in deb822_file.iter_parts():
            if isinstance(token_or_element, Deb822ParagraphElement):
                bits.append(reordered_stanza[stanza_idx].dump())
                stanza_idx += 1
            else:
                bits.append(token_or_element.convert_to_text())

        new_content = "".join(bits)

        return [
            TextEdit(
                Range(
                    Position(0, 0),
                    Position(len(lines) + 1, 0),
                ),
                new_content,
            )
        ]


class DTestsCtrlFileMetadata(
    Deb822FileMetadata[DTestsCtrlStanzaMetadata, DTestsCtrlKnownField]
):

    @property
    def reference_data_basename(self) -> str:
        return "debian_tests_control_reference_data.yaml"

    def _new_field(
        self,
        name: str,
        field_value_type: FieldValueClass,
    ) -> F:
        return DTestsCtrlKnownField(name, field_value_type)

    def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S:
        if stanza_idx >= 0:
            self.ensure_initialized()
            return _DTESTSCTRL_STANZA
        raise ValueError("The stanza_idx must be 0 or greater")

    def stanza_types(self) -> Iterable[S]:
        self.ensure_initialized()
        yield _DTESTSCTRL_STANZA

    def __getitem__(self, item: str) -> S:
        self.ensure_initialized()
        if item == "Tests":
            return _DTESTSCTRL_STANZA
        raise KeyError(item)


TRANSLATABLE_DEB822_FILE_METADATA: Sequence[
    Callable[[], Deb822FileMetadata[Any, Any]]
] = [
    DctrlFileMetadata,
    Dep5FileMetadata,
    DTestsCtrlFileMetadata,
]
