#!/usr/bin/python3
# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
#
# SPDX-License-Identifier: Zlib

"""Check for diagnostic messages not emitted in the manpages."""

from __future__ import annotations

import pathlib
import re
import sys
from typing import TYPE_CHECKING, Literal, NewType, cast

sys.path.append(str(pathlib.Path(sys.argv[0]).resolve().parent.parent / "src"))
from derivepassphrase._internals import cli_messages  # noqa: PLC2701

if TYPE_CHECKING:
    from collections.abc import Iterator

    EnumName = NewType("EnumName", str)
    DiagnosticText = NewType("DiagnosticText", str)

known_errors = cli_messages.ErrMsgTemplate.__members__
known_warnings = cli_messages.WarnMsgTemplate.__members__

mark_only = cast("DiagnosticText", "<mark only>")


def _replace_known_metavars(string: str) -> str:
    return (
        string.replace(
            "{service_metavar!s}",
            cli_messages.Label.VAULT_METAVAR_SERVICE.value.singular,
        )
        .replace(
            "{service_metavar}",
            cli_messages.Label.VAULT_METAVAR_SERVICE.value.singular,
        )
        .replace("{PROG_NAME!s}", cli_messages.PROG_NAME)
        .replace("{PROG_NAME}", cli_messages.PROG_NAME)
        .replace("{settings_type!s}", "global/service-specific settings")
        .replace("{settings_type}", "global/service-specific settings")
    )


# Use a double negative in the name ("does not mismatch text") because
# this is an error condition check, and if the enum name doesn't exist
# (because the manpage is outdated), then there is no mismatch.  This is
# clearer (to me at least) than erroneously claiming that a missing text
# matches the desired pattern.
def _mismatches_text(
    pattern: re.Pattern[str],
    enum_name: EnumName,
    name_type: Literal["warning", "error"],
) -> bool:
    while "." in enum_name:
        enum_name = cast("EnumName", enum_name.partition(".")[2])
    try:
        enum_value = (
            known_errors[enum_name].value
            if name_type == "error"
            else known_warnings[enum_name].value
        )
    except KeyError:
        # No text, so no mismatch.
        return False
    texts = {enum_value.singular, enum_value.plural} - {""}
    return not all(pattern.match(_replace_known_metavars(t)) for t in texts)


def _entries_from_text(
    text: DiagnosticText,
    enum_names: set[EnumName],
) -> Iterator[
    tuple[
        Literal["warning", "error"],
        tuple[DiagnosticText, EnumName],
    ]
]:
    assert text not in manpage_documented_warnings
    assert text not in manpage_documented_errors
    pattern_parts = [
        ".*" if part == "%s" else re.escape(part)
        for part in re.split(r"(%s)", text)
    ]
    pattern = re.compile("".join(pattern_parts))
    for name in enum_names:
        _class_name, dot, enum_entry = name.partition(".")
        assert dot == ".", f"Invalid enum name {name!r}"
        assert "." not in enum_entry, f"Unsupported enum name {name!r}"
        if name.startswith("WarnMsgTemplate."):
            assert not _mismatches_text(
                pattern, enum_name=name, name_type="warning"
            ), (
                f"Warning text for {name} doesn't match the manpage: "
                f"{text!r} -> {pattern.pattern!r}"
            )
            yield ("warning", (text, cast("EnumName", enum_entry)))
        if name.startswith("ErrMsgTemplate."):
            assert not _mismatches_text(
                pattern, enum_name=name, name_type="error"
            ), (
                f"Error text for {name} doesn't match the manpage: "
                f"{text!r} -> {pattern.pattern!r}"
            )
            yield ("error", (text, cast("EnumName", enum_entry)))


def _check_manpage(
    path: pathlib.Path,
) -> Iterator[
    tuple[
        Literal["warning", "error"],
        tuple[DiagnosticText, EnumName],
    ]
]:
    enum_names: set[EnumName] = set()

    for line in path.read_text(encoding="UTF-8").splitlines(keepends=False):
        if enum_names and line.startswith(".It "):
            # Some *roff escape sequences need to be undone.  This is not an
            # exhaustive list; new entries will be added based on the actual
            # manpages as the need arises.
            text = cast(
                "DiagnosticText",
                line.removeprefix(".It ").replace('"', "").replace(r"\-", "-"),
            )
            yield from _entries_from_text(text=text, enum_names=enum_names)
            enum_names.clear()
        elif line.startswith(r".\" Message-ID (mark only):"):
            yield from _entries_from_mark_only(
                cast("EnumName", line.split(None, 4)[4])
            )
        elif line.startswith(r".\" Message-ID:"):
            enum_names.add(cast("EnumName", line.split(None, 2)[2]))


def _entries_from_mark_only(
    name: EnumName,
) -> Iterator[
    tuple[
        Literal["warning", "error"],
        tuple[DiagnosticText, EnumName],
    ]
]:
    text = mark_only
    _class_name, dot, enum_entry = name.partition(".")
    assert dot == ".", f"Invalid enum name {name!r}"
    assert "." not in enum_entry, f"Unsupported enum name {name!r}"
    if name.startswith("WarnMsgTemplate."):
        yield ("warning", (text, cast("EnumName", enum_entry)))
    if name.startswith("ErrMsgTemplate."):
        yield ("error", (text, cast("EnumName", enum_entry)))


def _check_manpagedoc(
    path: pathlib.Path,
) -> Iterator[
    tuple[
        Literal["warning", "error"],
        tuple[DiagnosticText, EnumName],
    ]
]:
    enum_names: set[EnumName] = set()

    for line in path.read_text(encoding="UTF-8").splitlines(keepends=False):
        if enum_names and line.startswith(("??? failure ", "??? warning ")):
            text = cast("DiagnosticText", line.split(None, 2)[2])
            for ch in ['"', "`"]:
                assert text.startswith(ch)
                assert text.endswith(ch)
                text = cast("DiagnosticText", text[1:-1])
            yield from _entries_from_text(text=text, enum_names=enum_names)
            enum_names.clear()
        elif line.startswith("<!-- Message-ID (mark only):") and line.endswith(
            "-->"
        ):
            name = cast(
                "EnumName",
                line.removeprefix("<!-- Message-ID (mark only):")
                .removesuffix("-->")
                .strip(),
            )
            yield from _entries_from_mark_only(name)
        elif line.startswith("<!-- Message-ID:") and line.endswith("-->"):
            name = cast(
                "EnumName",
                line.removeprefix("<!-- Message-ID:")
                .removesuffix("-->")
                .strip(),
            )
            enum_names.add(name)


base = pathlib.Path(sys.argv[0]).resolve().parent.parent
manpage_documented_errors: dict[EnumName, DiagnosticText] = {}
manpage_documented_warnings: dict[EnumName, DiagnosticText] = {}
manpagedoc_documented_errors: dict[EnumName, DiagnosticText] = {}
manpagedoc_documented_warnings: dict[EnumName, DiagnosticText] = {}
marks: set[EnumName] = set()
for set_name, globs, errors, warnings in [
    (
        "manpages",
        sorted(
            pathlib.Path(base, "share", "man", "man1").glob(
                "derivepassphrase*.1"
            )
        ),
        manpage_documented_errors,
        manpage_documented_warnings,
    ),
    (
        "manpage-ish docs",
        sorted(
            pathlib.Path(base, "docs", "reference").glob(
                "derivepassphrase*.1.md"
            )
        ),
        manpagedoc_documented_errors,
        manpagedoc_documented_warnings,
    ),
]:
    for path in globs:
        print(f"CHECK DOC {str(path.relative_to(base))!r}")
        checker = (
            _check_manpage if set_name == "manpages" else _check_manpagedoc
        )
        for diagnostic_type, (text, name) in checker(path):
            if text == mark_only:
                marks.add(name)
            if diagnostic_type == "warning":
                warnings[name] = text
                print(f"DOC WARN {name} {text!r}")
            else:
                errors[name] = text
                print(f"DOC ERR {name} {text!r}")
    assert set(errors) >= set(known_errors), (
        f"Some error messages aren't documented in the {set_name}: "
        + repr(set(known_errors) - set(errors))
    )
    assert set(warnings) >= set(known_warnings), (
        f"Some warning messages aren't documented in the {set_name}: "
        + repr(set(known_warnings) - set(warnings))
    )
    assert set(errors) - marks <= set(known_errors), (
        f"Some unknown error messages are documented in the {set_name}: "
        + repr(set(errors) - marks - set(known_errors))  # type: ignore[arg-type]
    )
    assert set(warnings) - marks <= set(known_warnings), (
        f"Some unknown warning messages are documented in the {set_name}: "
        + repr(set(warnings) - marks - set(known_warnings))  # type: ignore[arg-type]
    )

py_file_errors: set[EnumName] = set()
py_file_warnings: set[EnumName] = set()
match_errors_warnings = re.compile(
    r"\b(?:cli_messages|msg|_msg)\.(Err|Warn)MsgTemplate\.([A-Z0-9_]+)"
)
for path in sorted(
    pathlib.Path(base, "src", "derivepassphrase").glob("**/*.py")
):
    if path != pathlib.Path(
        base, "src", "derivepassphrase", "_internals", "cli_messages.py"
    ):
        print(f"CHECK SOURCE {str(path.relative_to(base))!r}")
        filecontents = path.read_text(encoding="UTF-8")
        for match in match_errors_warnings.finditer(filecontents):
            message_type, symbol = match.group(1, 2)
            if message_type == "Err":
                py_file_errors.add(cast("EnumName", symbol))
                print(f"SOURCE ERR {symbol}")
            elif message_type == "Warn":
                py_file_warnings.add(cast("EnumName", symbol))
                print(f"SOURCE WARN {symbol}")
if py_file_errors != set(known_errors):
    print(
        "Some error messages aren't in use: "
        + repr(set(known_errors) - py_file_errors),
        file=sys.stderr,
    )
if py_file_warnings != set(known_warnings):
    print(
        "Some warning messages aren't in use: "
        + repr(set(known_warnings) - py_file_warnings),
        file=sys.stderr,
    )
