#!/usr/bin/env python3

import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent


def sources(*patterns):
    seen = set()
    for pattern in patterns:
        for path in sorted(ROOT.glob(pattern)):
            relative = path.relative_to(ROOT)
            if relative.parts[0].startswith((".", "build")) or relative in seen:
                continue
            seen.add(relative)
            yield relative, path.read_text(encoding="utf-8", errors="replace")


BYTE_ARRAY = re.compile(r'QByteArray\s*\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(\d+)\s*\)')
SIMPLE_ESCAPES = set('0abtnvfre"\'?\\')


def cpp_literal_body_bytes(text):
    count = 0
    index = 0
    while index < len(text):
        if text[index] != "\\":
            count += len(text[index].encode("utf-8"))
            index += 1
            continue
        index += 1
        if index >= len(text):
            raise ValueError("trailing backslash")
        mark = text[index]
        if mark == "x":
            index += 1
            start = index
            while index < len(text) and text[index] in "0123456789abcdefABCDEF":
                index += 1
            if index == start:
                raise ValueError("empty hex escape")
        elif mark in "01234567":
            digits = 0
            while index < len(text) and text[index] in "01234567" and digits < 3:
                index += 1
                digits += 1
        elif mark in SIMPLE_ESCAPES:
            index += 1
        else:
            raise ValueError(f"unknown escape \\{mark}")
        count += 1
    return count


def check_byte_array_lengths():
    problems = []
    for relative, source in sources("src/**/*.cpp", "src/**/*.h",
                                    "tests/**/*.cpp", "tests/**/*.h"):
        for number, line in enumerate(source.splitlines(), 1):
            for body, declared in BYTE_ARRAY.findall(line):
                try:
                    actual = cpp_literal_body_bytes(body)
                except ValueError as error:
                    problems.append(f"{relative}:{number}: cannot measure literal ({error})")
                    continue
                if actual != int(declared):
                    problems.append(
                        f"{relative}:{number}: QByteArray declares {declared} bytes, "
                        f"the literal is {actual}")
    return problems


def qml_brace_delta(text):
    delta = 0
    index = 0
    length = len(text)
    while index < length:
        char = text[index]
        if char in "\"'":
            quote = char
            index += 1
            while index < length:
                if text[index] == "\\":
                    index += 2
                    continue
                if text[index] == quote:
                    index += 1
                    break
                index += 1
            continue
        if char == "/" and index + 1 < length and text[index + 1] == "/":
            newline = text.find("\n", index)
            index = length if newline < 0 else newline
            continue
        if char == "/" and index + 1 < length and text[index + 1] == "*":
            end = text.find("*/", index + 2)
            index = length if end < 0 else end + 2
            continue
        if char == "/":
            prior = index - 1
            while prior >= 0 and text[prior] in " \t\r\n":
                prior -= 1
            previous = text[prior] if prior >= 0 else ""
            if previous == "" or not (previous.isalnum() or previous in "_)].\""):
                index += 1
                while index < length:
                    if text[index] == "\\":
                        index += 2
                        continue
                    if text[index] == "/":
                        index += 1
                        while index < length and text[index].isalpha():
                            index += 1
                        break
                    if text[index] == "\n":
                        break
                    index += 1
                continue
        if char == "{":
            delta += 1
        elif char == "}":
            delta -= 1
        index += 1
    return delta


def text_blocks(source, type_name="Text"):
    lines = source.splitlines()
    for index, line in enumerate(lines):
        if not re.match(rf"\s*{type_name}\s*\{{\s*$", line):
            continue
        depth = 1
        body = []
        for follower in lines[index + 1:]:
            depth += qml_brace_delta(follower)
            if depth <= 0:
                break
            body.append(follower)
        yield index + 1, "\n".join(body)


def check_qml_text_plain_format():
    problems = []
    for relative, source in sources("src/**/*.qml"):
        for number, body in text_blocks(source):
            if "plainIrcText(" not in body:
                continue
            if "textFormat: Text.PlainText" in body:
                continue
            if re.search(r"textFormat:.*Text\.PlainText", body, re.S):
                continue
            name = re.search(r'objectName:\s*"([^"]+)"', body)
            label = name.group(1) if name else "unnamed Text"
            problems.append(
                f"{relative}:{number}: {label} renders plainIrcText() without "
                f"textFormat: Text.PlainText")
    return problems


def check_qml_textedit_rich_emphasized():
    problems = []
    for relative, source in sources("src/**/*.qml"):
        for number, body in text_blocks(source, "TextEdit"):
            if "TextEdit.RichText" not in body:
                continue
            if "emphasizedIrcText(" in body:
                continue
            name = re.search(r'objectName:\s*"([^"]+)"', body)
            label = name.group(1) if name else "unnamed TextEdit"
            problems.append(
                f"{relative}:{number}: {label} uses RichText without "
                f"emphasizedIrcText()")
    return problems


def check_message_delegate_body():
    problems = []
    allowed = re.compile(
        r"(?:(?:win|ircText|IrcTextFormatter)\.)?(?:plainIrcText|emphasizedIrcText|hasIrcEmphasis)\(\s*"
        r"(?:win\.)?messageDelegate\.body")
    raw = re.compile(r"messageDelegate\.body")
    for relative, source in sources("src/**/*.qml"):
        allowed_spans = [(match.start(), match.end()) for match in allowed.finditer(source)]
        for match in raw.finditer(source):
            if any(start <= match.start() and match.end() <= end
                   for start, end in allowed_spans):
                continue
            number = source.count("\n", 0, match.start()) + 1
            problems.append(
                f"{relative}:{number}: raw messageDelegate.body")
    return problems


def check_socket_reads():
    problems = []
    for relative, source in sources("src/singleinstance.cpp", "src/omairccli.cpp"):
        for number, line in enumerate(source.splitlines(), 1):
            if "readAll()" in line:
                problems.append(
                    f"{relative}:{number}: readAll() on an IPC socket reads before "
                    f"the size cap; use read(remaining)")
    return problems


def check_retired_mock_sidebar():
    problems = []
    banned = (
        "liveFallbackSection",
        "preserveLegacyNames",
        "mockOmarchyId",
        "mockOftcId",
    )
    for relative, source in sources("src/**/*.qml", "src/**/*.cpp", "src/**/*.h"):
        for token in banned:
            if token in source:
                problems.append(
                    f"{relative}: {token} is a retired mock sidebar path")
    return problems


def check_host_win():
    problems = []
    pattern = re.compile(r"\bhost\s*:\s*win\b")
    for relative, source in sources("src/**/*.qml", "src/**/*.cpp", "src/**/*.h"):
        for number, line in enumerate(source.splitlines(), 1):
            if pattern.search(line):
                problems.append(
                    f"{relative}:{number}: host: win passes the window as a god-object")
    return problems


def check_style_exact_binding():
    problems = []
    # Value must be the identifier `style`. `style.something` and
    # `styleColor` are different bindings; `\b` alone treats the dot as a boundary.
    pattern = re.compile(r"\bstyle\s*:\s*style(?![A-Za-z0-9_.])")
    for relative, source in sources("src/**/*.qml"):
        for number, line in enumerate(source.splitlines(), 1):
            if pattern.search(line):
                problems.append(
                    f"{relative}:{number}: style: style; instantiate with "
                    f"style: win.style")
    return problems


def check_window_inline_components():
    problems = []
    pattern = re.compile(r"\bcomponent\s+([A-Za-z_][A-Za-z0-9_]*)\s*:")
    allowed = {"PlainUrlHit"}
    for relative, source in sources("src/OmaircWindow.qml"):
        for number, line in enumerate(source.splitlines(), 1):
            match = pattern.search(line)
            if match and match.group(1) not in allowed:
                problems.append(
                    f"{relative}:{number}: component {match.group(1)} "
                    f"belongs outside OmaircWindow.qml")
    return problems


ALLOWED_WINDOW_DELEGATES = {"messageDelegate", "consoleDelegate"}


def check_window_delegate_trees():
    problems = []
    binding = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*Delegate)\s*:\s*Item\b")
    ident = re.compile(r"\bid\s*:\s*([A-Za-z_][A-Za-z0-9_]*Delegate)\b")
    for relative, source in sources("src/OmaircWindow.qml"):
        for number, line in enumerate(source.splitlines(), 1):
            for pattern in (binding, ident):
                match = pattern.search(line)
                if match and match.group(1) not in ALLOWED_WINDOW_DELEGATES:
                    problems.append(
                        f"{relative}:{number}: delegate tree {match.group(1)} "
                        f"belongs outside OmaircWindow.qml")
                    break
    return problems


def check_qml_activate_duck_type():
    problems = []
    # Any receiver: kids[i].activate(), (child).activate(), get(i).activate().
    # Whitespace between the dot and the name still counts as a call.
    call = re.compile(r"\.\s*activate\s*\(")
    child = re.compile(r"\bchild\s*\.\s*activate\b")
    allowed = re.compile(
        r"\bslashCommands\s*\.\s*activate\s*\(\s*index\s*\)")
    window = Path("src/OmaircWindow.qml")
    for relative, source in sources("src/**/*.qml"):
        in_window = Path(relative) == window
        for number, line in enumerate(source.splitlines(), 1):
            call_spans = []
            allowed_spans = [
                match.span() for match in allowed.finditer(line)
            ] if in_window else []
            for match in call.finditer(line):
                call_spans.append(match.span())
                if any(start <= match.start() and match.end() <= end
                       for start, end in allowed_spans):
                    continue
                problems.append(
                    f"{relative}:{number}: .activate() duck-types a QML object")
            for match in child.finditer(line):
                # child.activate() is already a call; do not report it again.
                if any(match.start() < end and start < match.end()
                       for start, end in call_spans):
                    continue
                problems.append(
                    f"{relative}:{number}: child.activate duck-types a QML child")
    return problems


def check_hardcoded_chantypes():
    problems = []
    pattern = re.compile(
        r"QLatin1Char\('#'\).{0,80}QLatin1Char\('&'\).{0,80}"
        r"QLatin1Char\('\+'\).{0,80}QLatin1Char\('!'\)",
        re.DOTALL,
    )
    for relative, source in sources("src/**/*.cpp", "src/**/*.h"):
        if relative.name in ("ircserverfeatures.cpp", "ircserverfeatures.h"):
            continue
        if pattern.search(source):
            problems.append(
                f"{relative}: hard-coded CHANTYPES; call IrcServerFeatures")
    return problems


def check_version_source():
    problems = []
    for relative, source in sources("**/*.pro", "**/*.pri"):
        if relative.name == "version.pri":
            continue
        for number, line in enumerate(source.splitlines(), 1):
            if re.match(r"\s*VERSION\s*=", line) or "OMAIRC_VERSION=" in line:
                problems.append(
                    f"{relative}:{number}: set the version in version.pri and "
                    f"include it, do not repeat it here")
    return problems


# A version-looking string literal: major.minor with an optional patch and an
# optional letter or -/+ suffix (1.0.4, 1.0.4alpha, 1.0.4+main.g1). The
# trailing negative lookahead rejects a further dotted group so an IP address
# like 127.0.0.1 is not mistaken for a version.
GO_VERSION_STRING = (
    r'"(?P<number>v?[0-9]+\.[0-9]+(?:\.[0-9]+)?'
    r'(?:[A-Za-z][0-9A-Za-z]*|[-+][0-9A-Za-z.\-]+)?)'
    r'(?![0-9A-Za-z.\-])"'
)
GO_VERSION_DECL = re.compile(r"\b(?:const|var)\b[^\n=]*=\s*" + GO_VERSION_STRING)
GO_VERSION_BLOCK_DECL = re.compile(
    r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*" + GO_VERSION_STRING + r"\s*(?://.*)?$")
GO_VERSION_BLOCK_OPEN = re.compile(r"\b(?:const|var)\s*\(")
GO_VERSION_BLOCK_CLOSE = re.compile(r"^\s*\)\s*$")
GO_VERSION_SOURCE = Path("tui/internal/version/version.go")


def check_go_version_source():
    problems = []
    for relative, source in sources("tui/**/*.go"):
        if Path(relative) == GO_VERSION_SOURCE:
            continue
        in_block = False
        for number, line in enumerate(source.splitlines(), 1):
            if GO_VERSION_BLOCK_OPEN.search(line):
                in_block = True
            if in_block and GO_VERSION_BLOCK_CLOSE.match(line):
                in_block = False
                continue
            match = GO_VERSION_DECL.search(line)
            if not match and in_block:
                match = GO_VERSION_BLOCK_DECL.search(line)
            if not match:
                continue
            problems.append(
                f"{relative}:{number}: hard-coded version "
                f"{match.group('number')!r}; read internal/version.Value "
                f"(injected from version.pri) instead")
    return problems


def check_go_build_uses_version_pri():
    problems = []
    build = ROOT / "tui" / "bin" / "build"
    if not build.is_file():
        return ["tui/bin/build: missing; omairc-tui must build through it so "
                "version.Value is injected"]
    text = build.read_text(encoding="utf-8", errors="replace")
    if "bin/version" not in text:
        problems.append(
            "tui/bin/build: read the version from bin/version, not a second "
            "source")
    if "internal/version.Value" not in text:
        problems.append(
            "tui/bin/build: inject internal/version.Value so Go has one "
            "version source")
    if "-ldflags" not in text:
        problems.append(
            "tui/bin/build: inject version.Value through -ldflags")
    if "GOTOOLCHAIN=local" not in text:
        problems.append(
            "tui/bin/build: pin GOTOOLCHAIN=local like packaging/tui/PKGBUILD")
    return problems


GO_PLATFORM_IMPORTS = ("net", "os", "crypto/tls")

# A Go import target. The alias is optional and may be a bare identifier, a
# dot alias, or a blank alias; the path is quoted and a trailing `// comment`
# is allowed. One pattern covers a single-line `import ...` and a member of a
# parenthesized block alike.
GO_IMPORT_LINE = re.compile(
    r'^\s*(?:import\s+)?(?:[A-Za-z_.][A-Za-z0-9_.]*\s+)?'
    r'"([^"]+)"\s*(?://.*)?$')
GO_IMPORT_BLOCK_OPEN = re.compile(r'^\s*import\s*\(\s*(?://.*)?$')
GO_IMPORT_BLOCK_CLOSE = re.compile(r'^\s*\)\s*(?://.*)?$')


def go_imports(source):
    """Yield (line_number, import_path) for every Go import in source text.

    Handles single-line imports (`import "p"`, `import alias "p"`,
    `import . "p"`, `import _ "p"`) and parenthesized blocks, including
    aliases, dot/blank aliases, blank lines, and trailing `// comments`.
    Outside a block an `import` keyword is required so a statement such as
    `return "yes"` is not mistaken for an import.
    """
    in_block = False
    for number, line in enumerate(source.splitlines(), 1):
        if in_block:
            if GO_IMPORT_BLOCK_CLOSE.match(line):
                in_block = False
                continue
        else:
            if GO_IMPORT_BLOCK_OPEN.match(line):
                in_block = True
                continue
            if not line.lstrip().startswith("import"):
                continue
        match = GO_IMPORT_LINE.match(line)
        if match:
            yield number, match.group(1)


# URL and IP-literal parsing are pure functions, not a socket or TLS stack.
# avatarurl.go ports src/irc/ircavatarurl.cpp (QUrl + QHostAddress), so it needs
# net/url and net/netip. filelink.go ports src/irc/ircfilelink.cpp (QUrl).
# Every other internal/irc file stays fully neutral.
GO_IRC_PLATFORM_NEUTRAL_EXEMPT = {
    "tui/internal/irc/avatarurl.go": {"net/url", "net/netip"},
    "tui/internal/irc/filelink.go": {"net/url"},
}


def check_go_irc_platform_neutrality():
    # internal/irc is the portable core: sockets, clocks, the filesystem, and
    # TLS belong in internal/session. Test files may read testdata with os.
    problems = []
    for relative, source in sources("tui/internal/irc/*.go"):
        if relative.name.endswith("_test.go"):
            continue
        exempt = GO_IRC_PLATFORM_NEUTRAL_EXEMPT.get(
            Path(relative).as_posix(), set())
        for number, imported in go_imports(source):
            if imported in exempt:
                continue
            if imported in GO_PLATFORM_IMPORTS or imported.startswith("net/"):
                problems.append(
                    f"{relative}:{number}: internal/irc must stay platform "
                    f"neutral; {imported!r} belongs in internal/session")
    return problems


GO_INTERNAL_IRC_IMPORT = "github.com/fredimachado/omairc/tui/internal/irc"
GO_VIEW_PACKAGES = (
    ("tui/internal/ui/*.go", "internal/ui"),
    ("tui/internal/gate/*.go", "internal/gate"),
)


def check_go_view_no_protocol():
    # The view and the parity driver read controller snapshots and plain
    # strings; protocol policy belongs in internal/controller. Test files may
    # import what they assert against.
    problems = []
    for pattern, package in GO_VIEW_PACKAGES:
        for relative, source in sources(pattern):
            if relative.name.endswith("_test.go"):
                continue
            for number, imported in go_imports(source):
                if imported == GO_INTERNAL_IRC_IMPORT:
                    problems.append(
                        f"{relative}:{number}: {package} must not import "
                        f"internal/irc protocol policy; read controller "
                        f"snapshots instead")
    return problems


GO_TITLE_STRINGS = ("- Omairc", '" Status"')
GO_TITLE_SOURCE = Path("tui/internal/ui/title.go")


def check_go_title_strings():
    # The Qt window-title literals live only in title.go so the shell has one
    # place to change them. Tests may assert titles.
    problems = []
    for relative, source in sources("tui/**/*.go"):
        if Path(relative) == GO_TITLE_SOURCE or relative.name.endswith("_test.go"):
            continue
        for number, line in enumerate(source.splitlines(), 1):
            for literal in GO_TITLE_STRINGS:
                if literal in line:
                    problems.append(
                        f"{relative}:{number}: the window title literal "
                        f"{literal!r} belongs only in tui/internal/ui/title.go")
    return problems


def check_omairc_version_header():
    problems = []
    for relative, source in sources("src/**/*.cpp", "tests/**/*.cpp"):
        if "OMAIRC_VERSION" not in source:
            continue
        if "omaircversion.h" not in source:
            problems.append(
                f"{relative}: include omaircversion.h for OMAIRC_VERSION")
    return problems


def check_macos_objc_arc():
    problems = []
    mm = ROOT / "src" / "macosnotifications.mm"
    if not mm.is_file():
        return problems
    text = mm.read_text(encoding="utf-8", errors="replace")
    if "__has_feature(objc_arc)" not in text or "[content release]" not in text:
        problems.append(
            "src/macosnotifications.mm: keep an MRC [content release] gated on "
            "!__has_feature(objc_arc) so notify() cannot leak without ARC")
    for relative, source in sources("**/*.pro"):
        if "macosnotifications.mm" not in source:
            continue
        if "packaging/macos/objc-arc.pri" not in source:
            problems.append(
                f"{relative}: builds macosnotifications.mm without "
                f"packaging/macos/objc-arc.pri (-fobjc-arc)")
    return problems


def check_macos_ci_bash32():
    problems = []
    workflow = ROOT / ".github" / "workflows" / "macos.yml"
    if not workflow.is_file():
        return problems
    for number, line in enumerate(
            workflow.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
        code = line.split("#", 1)[0]
        if re.search(r"\b(mapfile|readarray)\b", code):
            problems.append(
                f".github/workflows/macos.yml:{number}: macOS /bin/bash is 3.2; "
                "do not use mapfile/readarray (Bash 4+)")
    return problems


def check_windows_ignores_linux_qtkeychain_deps():
    problems = []
    pri = ROOT / "qtkeychain.pri"
    text = pri.read_text(encoding="utf-8", errors="replace") if pri.is_file() else ""
    if not pri.is_file():
        problems.append("qtkeychain.pri: missing QtKeychain discovery")
        return problems
    if re.search(r"^} else:exists\(\$\$deps_dir/", text, re.M):
        problems.append(
            "qtkeychain.pri: .deps QtKeychain lookup must be unix-only; "
            "Windows must use the kit module, not a Linux .deps tree")
    if "unix {" not in text or "deps_dir/usr/mkspecs/modules/qt_Qt6Keychain.pri" not in text:
        problems.append(
            "qtkeychain.pri: keep Unix .deps discovery inside a unix { } block")
    return problems


def check_windows_about_svg_plugin():
    problems = []
    about = ROOT / "src" / "qml" / "AboutSheet.qml"
    build = ROOT / "bin" / "build.bat"
    workflow = ROOT / ".github" / "workflows" / "windows.yml"
    about_text = about.read_text(encoding="utf-8", errors="replace") \
        if about.is_file() else ""
    build_text = build.read_text(encoding="utf-8", errors="replace") \
        if build.is_file() else ""
    windows_text = workflow.read_text(encoding="utf-8", errors="replace") \
        if workflow.is_file() else ""
    if "qrc:/icons/omairc.svg" not in about_text:
        problems.append(
            "src/qml/AboutSheet.qml: About logo must load qrc:/icons/omairc.svg")
    if re.search(r"skip-plugin-types[^\n]*\bimageformats\b", build_text):
        problems.append(
            "bin/build.bat: do not skip imageformats; About needs qsvg.dll")
    if re.search(r'"!REL!\\imageformats"', build_text):
        problems.append(
            "bin/build.bat: do not rmdir imageformats; prune gif/ico only")
    if "qsvg.dll" not in windows_text:
        problems.append(
            ".github/workflows/windows.yml: Smoke must assert qsvg.dll is deployed")
    return problems


def check_windows_file_dialog():
    problems = []
    button = ROOT / "src" / "qml" / "FilePickButton.qml"
    build = ROOT / "bin" / "build.bat"
    workflow = ROOT / ".github" / "workflows" / "windows.yml"
    button_text = button.read_text(encoding="utf-8", errors="replace") \
        if button.is_file() else ""
    build_text = build.read_text(encoding="utf-8", errors="replace") \
        if build.is_file() else ""
    windows_text = workflow.read_text(encoding="utf-8", errors="replace") \
        if workflow.is_file() else ""
    if "import QtQuick.Dialogs" not in button_text or "FileDialog" not in button_text:
        problems.append(
            "src/qml/FilePickButton.qml: the file picker uses QtQuick.Dialogs FileDialog")
    if re.search(r'"!REL!\\qml\\QtQuick\\Dialogs"', build_text):
        problems.append(
            "bin/build.bat: do not prune qml\\QtQuick\\Dialogs; "
            "FilePickButton imports it and qt.conf keeps the exe on that tree")
    if "QtQuick\\Dialogs" not in windows_text and "QtQuick/Dialogs" not in windows_text:
        problems.append(
            ".github/workflows/windows.yml: Smoke must assert Qt Quick Dialogs is deployed")
    return problems


def check_macos_notarization():
    problems = []
    workflow = ROOT / ".github" / "workflows" / "macos.yml"
    sign = ROOT / "bin" / "sign-macos"
    notarize = ROOT / "bin" / "notarize-macos"
    entitlements = ROOT / "packaging" / "macos" / "Omairc.entitlements"
    if not workflow.is_file():
        return problems
    text = workflow.read_text(encoding="utf-8", errors="replace")
    notarize_text = notarize.read_text(encoding="utf-8", errors="replace") if notarize.is_file() else ""
    if "APPLE_CERTIFICATE" not in text or "OMAIRC_REQUIRE_DEVELOPER_ID" not in text:
        problems.append(
            ".github/workflows/macos.yml: import a Developer ID certificate and "
            "set OMAIRC_REQUIRE_DEVELOPER_ID so signed CI builds cannot silently "
            "fall back to ad-hoc")
    if "Tag releases require APPLE_CERTIFICATE" not in text:
        problems.append(
            ".github/workflows/macos.yml: v* tags must fail closed without "
            "APPLE_CERTIFICATE rather than shipping an ad-hoc zip")
    if "notarytool" not in notarize_text:
        problems.append(
            "bin/notarize-macos: notarize Developer ID zips with notarytool")
    if "bin/notarize-macos" not in text:
        problems.append(
            ".github/workflows/macos.yml: notarize release zips through "
            "bin/notarize-macos, not on every pull request")
    if "CODESIGN_IDENTITY" not in sign.read_text(encoding="utf-8", errors="replace"):
        problems.append(
            "bin/sign-macos: Developer ID sign when CODESIGN_IDENTITY is set")
    if "com.apple.security.cs.allow-jit" not in entitlements.read_text(
            encoding="utf-8", errors="replace"):
        problems.append(
            "packaging/macos/Omairc.entitlements: hardened runtime must allow "
            "QML JIT")
    if "disable-library-validation" in entitlements.read_text(
            encoding="utf-8", errors="replace"):
        problems.append(
            "packaging/macos/Omairc.entitlements: leave library validation on; "
            "Disable Library Validation makes Gatekeeper reject dangling "
            "LC_RPATH / LC_LOAD_DYLIB paths after a quarantined download")
    build = (ROOT / "bin" / "build-macos").read_text(encoding="utf-8", errors="replace")
    if "bin/sign-macos" not in build:
        problems.append("bin/build-macos: sign through bin/sign-macos")
    if "scrub_dangling_rpaths" not in build:
        problems.append(
            "bin/build-macos: strip absolute LC_RPATH entries before signing "
            "so a quarantined download is not a dangling-load-command reject")
    if "PlugIns/sqldrivers" not in build:
        problems.append(
            "bin/build-macos: prune Qt SQL plugins; they load Postgres.app / "
            "Homebrew dylibs outside the bundle")
    if "bin/check-macos-bundle" not in build or "bin/check-macos-bundle" not in text:
        problems.append(
            "call bin/check-macos-bundle from bin/build-macos and "
            ".github/workflows/macos.yml after Developer ID signing")
    if "bin/check-macos-syspolicy notary-submission" not in text:
        problems.append(
            ".github/workflows/macos.yml: syspolicy_check notary-submission "
            "after Developer ID signing (catches dangling load commands "
            "spctl misses without quarantine)")
    if "bin/check-macos-syspolicy distribution" not in text:
        problems.append(
            ".github/workflows/macos.yml: syspolicy_check distribution after "
            "stapling, before publishing the zip")
    syspolicy = ROOT / "bin" / "check-macos-syspolicy"
    if not syspolicy.is_file():
        problems.append("bin/check-macos-syspolicy: wrap syspolicy_check")
    else:
        syspolicy_text = syspolicy.read_text(encoding="utf-8", errors="replace")
        if "syspolicy_check" not in syspolicy_text:
            problems.append(
                "bin/check-macos-syspolicy: call syspolicy_check notary-submission "
                "and distribution")
        if "Internal Xprotect Error" not in syspolicy_text:
            problems.append(
                "bin/check-macos-syspolicy: ignore Internal Xprotect Error on "
                "GitHub-hosted runners; codesign and Apple notarization are "
                "the malware gate")
    if re.search(
            r"codesign --force --deep --options runtime",
            sign.read_text(encoding="utf-8", errors="replace")):
        problems.append(
            "bin/sign-macos: do not codesign --deep for Developer ID; sign "
            "nested Mach-O inside-out, then the bundle with entitlements")
    if "codesign --verify" not in text:
        problems.append(
            ".github/workflows/macos.yml: keep codesign --verify after Developer ID "
            "signing")
    if "stapler validate" not in text:
        problems.append(
            ".github/workflows/macos.yml: keep stapler validate after notarization")
    if "spctl --assess --type execute --verbose=4" not in text:
        problems.append(
            ".github/workflows/macos.yml: after stapling, assess Gatekeeper with "
            "spctl --assess --type execute --verbose=4 so a rejected app fails "
            "the release")
    if re.search(r"\bspctl\b", text) and "OMAIRC_NOTARIZED" not in text:
        problems.append(
            ".github/workflows/macos.yml: Gatekeeper assessment must run only after "
            "successful notarization (OMAIRC_NOTARIZED)")
    combined = text + "\n" + notarize_text
    if re.search(r"(--apple-id|\bAPPLE_ID\b|\bAPP_PASSWORD\b)", combined):
        problems.append(
            "macOS notarization must use the App Store Connect API key, not "
            "Apple ID / app-password")
    if re.search(r"com\.apple\.quarantine|\bxattr\b", combined):
        problems.append(
            "do not strip quarantine to bypass Gatekeeper; assess the stapled app")
    if re.search(r"(?i)\bsparkle\b", combined):
        problems.append("do not add Sparkle to macOS CI or notarization")
    return problems


def check_macos_ci_arch_selection():
    problems = []
    workflow = ROOT / ".github" / "workflows" / "macos.yml"
    if not workflow.is_file():
        return problems
    text = workflow.read_text(encoding="utf-8", errors="replace")
    if "qt_aqt_arch=clang_arm64" not in text:
        problems.append(
            ".github/workflows/macos.yml: arm64 runners must prefer aqt "
            "clang_arm64 (install may fall back to universal clang_64)")
    if "EXPECTED_MACHO_ARCH" not in text or "lipo -archs" not in text:
        problems.append(
            ".github/workflows/macos.yml: Smoke must lipo-check the binary "
            "against EXPECTED_MACHO_ARCH so macos-arm64/macos-x64 match")
    arm64_case = text.split("arm64)", 1)
    if len(arm64_case) > 1:
        arm64_body = arm64_case[1].split("x86_64)", 1)[0]
        if "clang_arm64" not in arm64_body:
            problems.append(
                ".github/workflows/macos.yml: arm64 case must prefer "
                "clang_arm64 before any clang_64 fallback")
    if "macos-15-intel" not in text:
        problems.append(
            ".github/workflows/macos.yml: Intel zips must build on macos-15-intel")
    if not re.search(r"(?m)^\s+- os: macos-15$", text):
        problems.append(
            ".github/workflows/macos.yml: Apple Silicon zips must build on macos-15; "
            "macos-latest is macOS 26 and does not ship Xcode 16")
    if "suffix: macos-arm64" not in text or "suffix: macos-x64" not in text:
        problems.append(
            ".github/workflows/macos.yml: matrix must include macos-arm64 and "
            "macos-x64 so both architecture zips are artifacts")
    if "omairc-*-macos-*.zip" in text:
        problems.append(
            ".github/workflows/macos.yml: do not glob every macOS zip when "
            "replacing a release asset; that deletes the other architecture")
    if "actions/download-artifact@v8" not in text or "macos-arm64.zip" not in text \
            or "macos-x64.zip" not in text:
        problems.append(
            ".github/workflows/macos.yml: a follow-up job must download both "
            "macos-arm64 and macos-x64 artifacts so a missing zip fails the run")
    return problems


def _named_step_header(text, name):
    match = re.search(
        rf"(?m)^      - name: {re.escape(name)}\n((?:        .+\n){{0,8}})",
        text,
    )
    return match.group(0) if match else ""


def check_ci_package_triggers():
    problems = []
    windows = ROOT / ".github" / "workflows" / "windows.yml"
    macos = ROOT / ".github" / "workflows" / "macos.yml"
    release = ROOT / ".github" / "workflows" / "release-package.yml"
    windows_text = windows.read_text(encoding="utf-8", errors="replace") \
        if windows.is_file() else ""
    macos_text = macos.read_text(encoding="utf-8", errors="replace") \
        if macos.is_file() else ""
    release_text = release.read_text(encoding="utf-8", errors="replace") \
        if release.is_file() else ""

    if not windows.is_file():
        problems.append(".github/workflows/windows.yml: missing Windows workflow")
    if not macos.is_file():
        problems.append(".github/workflows/macos.yml: missing macOS workflow")
    if not release.is_file():
        problems.append(
            ".github/workflows/release-package.yml: missing Arch package workflow")

    package_if = (
        "if: github.event_name == 'workflow_dispatch' || "
        "startsWith(github.ref, 'refs/tags/v')"
    )
    for relative, text, names in (
        (".github/workflows/windows.yml", windows_text, (
            "Upload portable tree",
            "Install Inno Setup",
            "Package installer",
            "Smoke installer",
            "Upload installer",
        )),
        (".github/workflows/macos.yml", macos_text, (
            "Package app zip",
            "Upload app zip",
            "Download architecture zips",
            "Assert both architecture zips",
        )),
    ):
        for name in names:
            header = _named_step_header(text, name)
            if not header:
                problems.append(f"{relative}: missing step {name!r}")
                continue
            if package_if not in header:
                problems.append(
                    f"{relative}: {name} must package only on workflow_dispatch "
                    "and v* tags; pull requests and main skip artifacts")

    portable = _named_step_header(macos_text, "Portable tests")
    if not portable:
        problems.append(".github/workflows/macos.yml: missing step 'Portable tests'")
    elif "matrix.suffix == 'macos-arm64'" not in portable \
            or "github.event_name != 'pull_request'" not in portable:
        problems.append(
            ".github/workflows/macos.yml: Intel pull requests must skip "
            "Portable tests; Apple Silicon PRs and non-PR runs keep them")

    skip = _named_step_header(macos_text, "Skip zip artifacts")
    if not skip:
        problems.append(
            ".github/workflows/macos.yml: Both macOS zips must stay a green "
            "no-op when this run does not package, so a skipped download is "
            "not a failed check")
    elif "workflow_dispatch" not in skip or "refs/tags/v" not in skip:
        problems.append(
            ".github/workflows/macos.yml: Skip zip artifacts must no-op on "
            "pull requests and main, not only pull requests")

    if re.search(r"(?m)^  push:\n    branches:\n      - main\b", release_text):
        problems.append(
            ".github/workflows/release-package.yml: do not package on main "
            "pushes; keep v* tags and workflow_dispatch")

    tui = ROOT / ".github" / "workflows" / "tui.yml"
    tui_text = tui.read_text(encoding="utf-8", errors="replace") \
        if tui.is_file() else ""
    if not tui.is_file():
        problems.append(".github/workflows/tui.yml: missing TUI workflow")
    else:
        for name in ("Package static archives", "Upload static archives"):
            header = _named_step_header(tui_text, name)
            if not header:
                problems.append(f".github/workflows/tui.yml: missing step {name!r}")
                continue
            if package_if not in header:
                problems.append(
                    f".github/workflows/tui.yml: {name} must package only on "
                    "workflow_dispatch and v* tags; pull requests and main "
                    "skip artifacts")
        if "package-macos" in tui_text:
            problems.append(
                ".github/workflows/tui.yml: release jobs must not call "
                "tui/bin/package-macos")
        if "cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}" \
                not in tui_text:
            problems.append(
                ".github/workflows/tui.yml: tag concurrency must not cancel "
                "an in-progress release")
        if len(re.findall(r"(?m)^\s+contents: write\s*$", tui_text)) != 2:
            problems.append(
                ".github/workflows/tui.yml: contents: write belongs on the "
                "package and bump jobs only")
        if "bin/bump-homebrew-cask" in tui_text or "Casks/omairc.rb" in tui_text:
            problems.append(
                ".github/workflows/tui.yml: the TUI bump must not touch "
                "Casks/omairc.rb")
        if "bin/bump-homebrew-tui" not in tui_text or "bin/bump-scoop-tui" not in tui_text:
            problems.append(
                ".github/workflows/tui.yml: version tags must run "
                "bin/bump-homebrew-tui and bin/bump-scoop-tui")
        if "git fetch origin main" not in tui_text \
                or "git rebase origin/main" not in tui_text \
                or "max_attempts=5" not in tui_text:
            problems.append(
                ".github/workflows/tui.yml: manifest bump must fetch and "
                "rebase onto origin/main, retrying a raced push")
        if "packaging/tui/PKGBUILD" not in tui_text or "makepkg" not in tui_text:
            problems.append(
                ".github/workflows/tui.yml: dry-build packaging/tui/PKGBUILD "
                "on pull requests; release-package.yml's tag job must not be "
                "its first build")
        if "if: ${{ !startsWith(github.ref, 'refs/tags/v') }}" not in tui_text:
            problems.append(
                ".github/workflows/tui.yml: the packaging/tui/PKGBUILD dry "
                "build must skip tags; release-package.yml builds the package "
                "there")
    return problems


def check_macos_ci_xcode_and_actions():
    problems = []
    workflow = ROOT / ".github" / "workflows" / "macos.yml"
    if not workflow.is_file():
        return problems
    text = workflow.read_text(encoding="utf-8", errors="replace")
    if "maxim-lobanov/setup-xcode@v1" not in text or "xcode-version:" not in text:
        problems.append(
            ".github/workflows/macos.yml: select Xcode with maxim-lobanov/setup-xcode "
            "and an exact xcode-version; do not fall back to the runner default")
    if "No Xcode 16 install found" in text or "Prefer Xcode 16 when available" in text:
        problems.append(
            ".github/workflows/macos.yml: do not keep a best-effort Xcode 16 "
            "shell fallback; setup-xcode must fail closed")
    versions = re.findall(r"xcode-version:\s*['\"]?([^\s'\"}]+)", text)
    versions += re.findall(r'(?m)^\s+xcode:\s*"([^"]+)"', text)
    concrete = [version for version in versions if not version.startswith("${{")]
    if not concrete:
        problems.append(
            ".github/workflows/macos.yml: pin an exact Xcode 16 release in "
            "xcode-version or the matrix xcode field")
    for version in concrete:
        if not re.fullmatch(r"16\.\d+", version):
            problems.append(
                ".github/workflows/macos.yml: Xcode pin must be an exact 16.x "
                f"release (Qt 6.8.3), not {version!r}")
    required = (
        ("actions/checkout@v7", "actions/checkout@v7"),
        ("actions/cache@v6", "actions/cache@v6"),
        ("actions/upload-artifact@v7", "actions/upload-artifact@v7"),
        ("actions/download-artifact@v8", "actions/download-artifact@v8"),
    )
    for needle, label in required:
        if needle not in text:
            problems.append(
                f".github/workflows/macos.yml: use {label} (current Node 24 major)")
    stale = (
        r"actions/checkout@v[1-6]\b",
        r"actions/cache@v[1-5]\b",
        r"actions/upload-artifact@v[1-6]\b",
        r"actions/download-artifact@v[1-7]\b",
    )
    for pattern in stale:
        if re.search(pattern, text):
            problems.append(
                f".github/workflows/macos.yml: replace stale action pin matching "
                f"{pattern}")
    if "contents: write" not in text:
        problems.append(
            ".github/workflows/macos.yml: keep contents: write for release assets")
    if re.search(r"(id-token|pages|pull-requests):\s*write", text):
        problems.append(
            ".github/workflows/macos.yml: do not broaden workflow permissions "
            "beyond contents: write")
    return problems


def check_macos_sdk_compat():
    problems = []
    pri = ROOT / "packaging" / "macos" / "sdk-compat.pri"
    if not pri.is_file():
        return ["packaging/macos/sdk-compat.pri: missing SDK compatibility pri"]
    text = pri.read_text(encoding="utf-8", errors="replace")
    if "-include arm_acle.h" not in text:
        problems.append(
            "packaging/macos/sdk-compat.pri: Apple Silicon still needs "
            "-include arm_acle.h for Qt 6.8 qyieldcpu.h on Xcode 26")
    if "equals(QMAKE_HOST.arch, arm64)" not in text:
        problems.append(
            "packaging/macos/sdk-compat.pri: -include arm_acle.h must be gated "
            "to Apple Silicon; Intel fails with ACLE not enabled")
    for relative, source in sources("**/*.pro"):
        if "-include arm_acle.h" not in source:
            continue
        if "equals(QMAKE_HOST.arch, arm64)" in source:
            continue
        problems.append(
            f"{relative}: -include arm_acle.h must go through "
            "packaging/macos/sdk-compat.pri so Intel builds skip it")
    return problems


def macos_readme_section(text):
    start = text.find("## macOS")
    if start < 0:
        return ""
    rest = text[start + 2:]
    next_heading = rest.find("\n## ")
    if next_heading < 0:
        return text[start:]
    return text[start:start + 2 + next_heading]


def _omairc_star_globs(text):
    """Lines whose omairc- glob can also match an omairc-tui asset."""
    found = []
    for match in re.finditer(r"omairc-", text):
        rest = text[match.end():]
        if rest.startswith("tui-") or rest.startswith("[0-9]"):
            continue
        if not rest.startswith("*"):
            continue
        line_start = text.rfind("\n", 0, match.start()) + 1
        line_end = text.find("\n", match.end())
        if line_end < 0:
            line_end = len(text)
        found.append(text[line_start:line_end].strip())
    return found


def check_go_read_build_info():
    problems = []
    sentinel = "0.0.0-dev"
    for relative, source in sources("tui/**/*.go"):
        if Path(relative) == GO_VERSION_SOURCE:
            continue
        if "ReadBuildInfo" in source:
            problems.append(
                f"{relative}: debug.ReadBuildInfo is only allowed in "
                f"{GO_VERSION_SOURCE.as_posix()}")
        if sentinel in source:
            problems.append(
                f"{relative}: the {sentinel} sentinel lives only in "
                f"{GO_VERSION_SOURCE.as_posix()}")
    version_go = ROOT / GO_VERSION_SOURCE
    if not version_go.is_file():
        return [f"{GO_VERSION_SOURCE.as_posix()}: missing version fallback"] + problems
    text = version_go.read_text(encoding="utf-8", errors="replace")
    if sentinel not in text:
        problems.append(
            f"{GO_VERSION_SOURCE.as_posix()}: keep the {sentinel} sentinel "
            "for builds that do not inject a version")
    if "ReadBuildInfo" not in text:
        problems.append(
            f"{GO_VERSION_SOURCE.as_posix()}: when Value is still {sentinel}, "
            "fall back to debug.ReadBuildInfo")
    return problems


def check_tui_static_package():
    problems = []
    package = ROOT / "tui" / "bin" / "package"
    if not package.is_file():
        return ["tui/bin/package: missing static archive script"]
    text = package.read_text(encoding="utf-8", errors="replace")
    required = (
        ("bin/version", "read the version from bin/version"),
        ("OMAIRC_BUILD_VERSION", "honor OMAIRC_BUILD_VERSION"),
        ("CGO_ENABLED=0", "set CGO_ENABLED=0"),
        ("GOTOOLCHAIN=local", "pin GOTOOLCHAIN=local like packaging/tui/PKGBUILD"),
        ("-trimpath", "pass -trimpath"),
        ("internal/version.Value", "inject internal/version.Value"),
        ("-ldflags", "inject the version through -ldflags"),
        ("-s -w", "strip the release binary with -s -w"),
        ('./cmd/omairc-tui', "build only ./cmd/omairc-tui"),
        ("--mtime", "pin the archive mtime so release tarballs are reproducible"),
    )
    for needle, message in required:
        if needle not in text:
            problems.append(f"tui/bin/package: {message}")
    if "control-omairc-tui" in text:
        problems.append(
            "tui/bin/package: do not ship control-omairc-tui")
    return problems


def check_tui_install_script():
    problems = []
    path = ROOT / "install-tui.sh"
    if not path.is_file():
        return ["install-tui.sh: missing the portable terminal-client installer"]
    # Git records 100755, but a Windows checkout cannot show it; skip there.
    if sys.platform != "win32" and not path.stat().st_mode & 0o111:
        problems.append("install-tui.sh: must be executable")
    text = path.read_text(encoding="utf-8", errors="replace")
    required = (
        ("set -eu", "must fail fast"),
        ("https://github.com/fredimachado/omairc/releases",
         "must install from the GitHub release"),
        ("url_effective",
         "must resolve the latest release from the /latest redirect"),
        ("OMAIRC_TUI_VERSION", "must let a caller pin the version"),
        ("OMAIRC_TUI_BIN_DIR", "must let a caller choose the install directory"),
        ("$HOME/.local/bin", "must default to a user-local install directory"),
        ("Linux)", "must support Linux"),
        ("Darwin)", "must support macOS"),
        ("amd64", "must install the amd64 archive on x86_64"),
        ("arm64", "must install the arm64 archive on arm64 and aarch64"),
        ("tar.gz", "must install the .tar.gz release archive"),
        ("--version", "must verify the downloaded binary before installing it"),
        ("install -m 0755", "must install the binary executable"),
    )
    for needle, message in required:
        if needle not in text:
            problems.append(f"install-tui.sh: {message}")
    if re.search(r"(?m)(?:^|\s)sudo(?:\s|$)", text):
        problems.append(
            "install-tui.sh: must not require sudo; install under $HOME")
    if "raw.githubusercontent.com/fredimachado/omairc/main/install-tui.sh" \
            not in text:
        problems.append("install-tui.sh: usage must show the curl one-liner")
    for relative in ("README.md", "tui/README.md"):
        doc = ROOT / relative
        if not doc.is_file() or "install-tui.sh" not in doc.read_text(
                encoding="utf-8", errors="replace"):
            problems.append(f"{relative}: document install-tui.sh for omairc-tui")
    return problems


def check_tui_release_assets():
    problems = []
    # Anything that acts on release asset names, plus the user-facing install
    # instructions, must not use an omairc-* glob that also matches omairc-tui.
    files = (
        ".github/workflows/windows.yml",
        ".github/workflows/macos.yml",
        ".github/workflows/release-package.yml",
        ".github/workflows/tui.yml",
        "install.sh",
        "install-tui.sh",
        "README.md",
    )
    for relative in files:
        path = ROOT / relative
        if not path.is_file():
            problems.append(f"{relative}: missing file")
            continue
        for line in _omairc_star_globs(path.read_text(encoding="utf-8", errors="replace")):
            problems.append(
                f"{relative}: omairc-* glob can also match an "
                f"omairc-tui asset: {line}")

    release = ROOT / ".github" / "workflows" / "release-package.yml"
    if release.is_file():
        text = release.read_text(encoding="utf-8", errors="replace")
        if "tui/**" not in text or "packaging/tui/**" not in text:
            problems.append(
                ".github/workflows/release-package.yml: tag path filters "
                "must include tui/** and packaging/tui/**")
        if "omairc-tui-*-x86_64.pkg.tar.zst" not in text:
            problems.append(
                ".github/workflows/release-package.yml: repo-add the "
                "omairc-tui package into omairc.db")
        if "install-tui.sh" not in text:
            problems.append(
                ".github/workflows/release-package.yml: filter and upload "
                "install-tui.sh beside install.sh")
        if re.search(r"(?m)^  push:\n    branches:\n      - main\b", text):
            problems.append(
                ".github/workflows/release-package.yml: do not package on "
                "main pushes")

    formula = ROOT / "Formula" / "omairc-tui.rb"
    if not formula.is_file():
        problems.append("Formula/omairc-tui.rb: missing Homebrew formula")
    else:
        text = formula.read_text(encoding="utf-8", errors="replace")
        if "class OmaircTui < Formula" not in text:
            problems.append("Formula/omairc-tui.rb: declare class OmaircTui")
        if 'bin.install "omairc-tui"' not in text:
            problems.append('Formula/omairc-tui.rb: bin.install "omairc-tui"')
        if "strategy :github_latest" not in text:
            problems.append(
                "Formula/omairc-tui.rb: livecheck must use :github_latest")
        if "omairc-tui-#{version}-darwin-arm64.tar.gz" not in text \
                or "omairc-tui-#{version}-darwin-amd64.tar.gz" not in text:
            problems.append(
                "Formula/omairc-tui.rb: url must be the darwin arm64 and "
                "amd64 release tarballs")
        if re.search(r'^\s*app\s+"', text, re.MULTILINE):
            problems.append("Formula/omairc-tui.rb: do not install an app bundle")
        if 'depends_on "go"' in text:
            problems.append('Formula/omairc-tui.rb: do not depends_on "go"')

    manifest = ROOT / "bucket" / "omairc-tui.json"
    if not manifest.is_file():
        problems.append("bucket/omairc-tui.json: missing Scoop manifest")
    else:
        text = manifest.read_text(encoding="utf-8", errors="replace")
        if '"bin": "omairc-tui.exe"' not in text:
            problems.append('bucket/omairc-tui.json: bin must be omairc-tui.exe')
        if '"checkver"' not in text or '"autoupdate"' not in text:
            problems.append(
                "bucket/omairc-tui.json: keep checkver and autoupdate")
        install_section, _, autoupdate_section = text.partition('"autoupdate"')
        version_match = re.search(r'"version"\s*:\s*"([^"]+)"', text)
        install_url_match = re.search(
            r'"architecture"\s*:\s*\{\s*"64bit"\s*:\s*\{\s*"url"\s*:\s*"([^"]+)"',
            install_section)
        if version_match is None:
            problems.append("bucket/omairc-tui.json: missing version")
        elif install_url_match is None:
            problems.append(
                "bucket/omairc-tui.json: missing architecture 64bit url")
        else:
            install_url = install_url_match.group(1)
            if "$version" in install_url:
                problems.append(
                    "bucket/omairc-tui.json: install url must be literal; "
                    "$version belongs in autoupdate only")
            elif not install_url.endswith(
                    f"omairc-tui-{version_match.group(1)}-windows-amd64.zip"):
                problems.append(
                    "bucket/omairc-tui.json: install url must be the "
                    "windows-amd64 zip")
        if "omairc-tui-$version-windows-amd64.zip" not in autoupdate_section:
            problems.append(
                "bucket/omairc-tui.json: autoupdate url must keep "
                "omairc-tui-$version-windows-amd64.zip")

    homebrew = ROOT / "bin" / "bump-homebrew-tui"
    scoop = ROOT / "bin" / "bump-scoop-tui"
    if not homebrew.is_file():
        problems.append(
            "bin/bump-homebrew-tui: rewrite Formula/omairc-tui.rb from the "
            "darwin tarballs")
    else:
        text = homebrew.read_text(encoding="utf-8", errors="replace")
        if "Casks/omairc.rb" not in text or "refusing to open" not in text:
            problems.append(
                "bin/bump-homebrew-tui: must refuse to open Casks/omairc.rb")
        if "Formula" not in text or "darwin-" not in text \
                or '"arm64"' not in text or '"amd64"' not in text:
            problems.append(
                "bin/bump-homebrew-tui: must hash both darwin-arm64 and "
                "darwin-amd64 tarballs into Formula/omairc-tui.rb")
    if not scoop.is_file():
        problems.append(
            "bin/bump-scoop-tui: rewrite bucket/omairc-tui.json from the "
            "Windows zip")
    else:
        text = scoop.read_text(encoding="utf-8", errors="replace")
        if "bucket" not in text or "windows-amd64" not in text:
            problems.append(
                "bin/bump-scoop-tui: must rewrite the windows-amd64 hash in "
                "bucket/omairc-tui.json")
        if "autoupdate" not in text or "$version" not in text:
            problems.append(
                "bin/bump-scoop-tui: fill the literal install url from the "
                "autoupdate $version template")
        if "Casks" in text and "refusing to open" not in text:
            problems.append(
                "bin/bump-scoop-tui: must refuse to open Casks/omairc.rb")
    return problems


def check_homebrew_cask():
    problems = []
    path = ROOT / "Casks" / "omairc.rb"
    bump = ROOT / "bin" / "bump-homebrew-cask"
    workflow = ROOT / ".github" / "workflows" / "macos.yml"
    if (ROOT / "packaging" / "homebrew").exists():
        problems.append(
            "packaging/homebrew: cask lives in Casks/omairc.rb so "
            "`brew tap fredimachado/omairc` can see it")
    if not path.is_file():
        return ["Casks/omairc.rb: missing Homebrew cask"] + problems
    text = path.read_text(encoding="utf-8", errors="replace")
    if 'cask "omairc" do' not in text:
        problems.append('Casks/omairc.rb: declare cask "omairc"')
    if 'arch arm: "arm64", intel: "x64"' not in text:
        problems.append(
            "Casks/omairc.rb: dual GitHub zips need "
            'arch arm: "arm64", intel: "x64"')
    if "omairc-#{version}-macos-#{arch}.zip" not in text:
        problems.append(
            "Casks/omairc.rb: url must interpolate the GitHub "
            "omairc-#{version}-macos-#{arch}.zip asset")
    if "github.com/fredimachado/omairc/releases/download" not in text:
        problems.append(
            "Casks/omairc.rb: url must be the GitHub release zip")
    if not re.search(r'sha256 arm:\s+"[0-9a-f]{64}"', text):
        problems.append("Casks/omairc.rb: sha256 arm must be a real digest")
    if not re.search(r'intel:\s+"[0-9a-f]{64}"', text):
        problems.append("Casks/omairc.rb: sha256 intel must be a real digest")
    if "strategy :github_latest" not in text:
        problems.append(
            "Casks/omairc.rb: livecheck must track GitHub latest")
    if "depends_on :macos" not in text and "depends_on macos: :big_sur" not in text:
        problems.append(
            "Casks/omairc.rb: depends_on :macos (LSMinimumSystemVersion)")
    if 'app "omairc.app"' not in text:
        problems.append('Casks/omairc.rb: install omairc.app')
    if 'binary "#{appdir}/omairc.app/Contents/MacOS/omairc"' in text:
        problems.append(
            "Casks/omairc.rb: do not symlink Contents/MacOS/omairc; Qt then "
            "misses the bundle qt.conf")
    if 'binary "omairc"' not in text:
        problems.append(
            "Casks/omairc.rb: expose the CLI binary on PATH")
    if "preflight_steps" not in text \
            or 'exec "{{appdir}}/omairc.app/Contents/MacOS/omairc"' not in text:
        problems.append(
            "Casks/omairc.rb: PATH helper must exec the bundle Mach-O so Qt "
            "reads qt.conf")
    if "caveats" not in text or "Start Omairc first" not in text:
        problems.append(
            "Casks/omairc.rb: caveats must say the CLI needs a running window")
    if "~/Library/Preferences/omairc" not in text:
        problems.append(
            "Casks/omairc.rb: zap must use macOS "
            "GenericConfigLocation (~/Library/Preferences/omairc)")
    if "~/Library/Preferences/State/omairc" not in text:
        problems.append(
            "Casks/omairc.rb: zap must use macOS "
            "GenericStateLocation (~/Library/Preferences/State/omairc)")
    if re.search(r"XDG_|~/\.config/omairc|Application Support/omairc", text):
        problems.append(
            "Casks/omairc.rb: zap must not use Linux $XDG_* or "
            "guessed Application Support paths")
    if re.search(r"uninstall[^\n]*delete", text) or "uninstall delete:" in text:
        problems.append(
            "Casks/omairc.rb: uninstall must leave config; put user files in zap")
    if "no-quarantine" in text or "xattr" in text:
        problems.append("Casks/omairc.rb: do not teach --no-quarantine")
    if "bin/build-macos" in text or "qt@6" in text:
        problems.append(
            "Casks/omairc.rb: do not cask a Homebrew-Qt from-source tree")

    if not bump.is_file():
        problems.append(
            "bin/bump-homebrew-cask: rewrite Casks/omairc.rb from the tagged zips")
    else:
        bump_text = bump.read_text(encoding="utf-8", errors="replace")
        if "sha256" not in bump_text or "Casks" not in bump_text:
            problems.append(
                "bin/bump-homebrew-cask: must rewrite Casks/omairc.rb version "
                "and sha256")
        if "macos-arm64" not in bump_text or "macos-x64" not in bump_text:
            problems.append(
                "bin/bump-homebrew-cask: must hash both macos-arm64 and "
                "macos-x64 zips")
    workflow_text = workflow.read_text(encoding="utf-8", errors="replace") \
        if workflow.is_file() else ""
    if "bin/bump-homebrew-cask" not in workflow_text:
        problems.append(
            ".github/workflows/macos.yml: version tags must run "
            "bin/bump-homebrew-cask after both zips exist")
    if "needs: macos-zips" not in workflow_text:
        problems.append(
            ".github/workflows/macos.yml: Homebrew cask bump must wait for "
            "both architecture zips")
    if "git fetch origin main" not in workflow_text \
            or "git rebase origin/main" not in workflow_text:
        problems.append(
            ".github/workflows/macos.yml: cask bump must fetch and rebase "
            "onto origin/main when the push is not a fast-forward")
    if "max_attempts=5" not in workflow_text:
        problems.append(
            ".github/workflows/macos.yml: cask bump must retry a raced "
            "main push instead of leaving the cask on the prior version")
    if re.search(r"\*fetch first\*", workflow_text):
        problems.append(
            ".github/workflows/macos.yml: do not use *fetch first* as a "
            "case pattern; the space splits the arm and bash dies before "
            "git push")

    readme = ROOT / "README.md"
    section = macos_readme_section(
        readme.read_text(encoding="utf-8", errors="replace") if readme.is_file() else "")
    if not section:
        problems.append("README.md: missing ## macOS section")
        return problems
    if "raw.githubusercontent.com" in section:
        problems.append(
            "README.md: do not install the cask from a raw GitHub URL; tap Casks/")
    tap_pos = section.find(
        "brew tap fredimachado/omairc https://github.com/fredimachado/omairc")
    qualified_pos = section.find("brew install --cask fredimachado/omairc/omairc")
    official = re.search(r"(?m)^brew install --cask omairc$", section)
    zip_pos = section.find("omairc-<version>-macos-arm64.zip")
    source_pos = section.find("bin/build-macos")
    if tap_pos < 0:
        problems.append(
            "README.md: macOS section must tap fredimachado/omairc with the "
            "repo URL")
    if qualified_pos < 0:
        problems.append(
            "README.md: macOS section must brew install --cask "
            "fredimachado/omairc/omairc")
    if official is None:
        problems.append(
            "README.md: macOS section must keep brew install --cask omairc "
            "for when Homebrew has the cask")
    if zip_pos < 0:
        problems.append("README.md: macOS section must mention the GitHub zip")
    if source_pos < 0:
        problems.append("README.md: macOS section must keep the from-source build")
    if tap_pos >= 0 and zip_pos >= 0 and tap_pos > zip_pos:
        problems.append(
            "README.md: macOS section must lead with the tap, then the zip")
    if zip_pos >= 0 and source_pos >= 0 and zip_pos > source_pos:
        problems.append(
            "README.md: macOS section must put the zip before the from-source build")
    return problems


CHECKS = (
    ("QByteArray literal lengths", check_byte_array_lengths),
    ("PlainText on server-controlled QML text", check_qml_text_plain_format),
    ("RichText through emphasizedIrcText", check_qml_textedit_rich_emphasized),
    ("formatted messageDelegate.body", check_message_delegate_body),
    ("bounded IPC socket reads", check_socket_reads),
    ("single version source", check_version_source),
    ("Go version source", check_go_version_source),
    ("Go build reads version.pri", check_go_build_uses_version_pri),
    ("Go ReadBuildInfo version fallback", check_go_read_build_info),
    ("TUI static package script", check_tui_static_package),
    ("TUI install script", check_tui_install_script),
    ("TUI release assets", check_tui_release_assets),
    ("internal/irc platform neutrality", check_go_irc_platform_neutrality),
    ("Go view/driver free of internal/irc", check_go_view_no_protocol),
    ("Go title strings only in title.go", check_go_title_strings),
    ("OMAIRC_VERSION via omaircversion.h", check_omairc_version_header),
    ("CHANTYPES via IrcServerFeatures", check_hardcoded_chantypes),
    ("retired mock sidebar tokens", check_retired_mock_sidebar),
    ("host: win god-object", check_host_win),
    ("style: style binding", check_style_exact_binding),
    ("OmaircWindow inline components", check_window_inline_components),
    ("OmaircWindow delegate trees", check_window_delegate_trees),
    ("QML child.activate duck-typing", check_qml_activate_duck_type),
    ("macOS ObjC ARC for notifications", check_macos_objc_arc),
    ("macOS CI Qt arch selection", check_macos_ci_arch_selection),
    ("CI packages only on tags and dispatch", check_ci_package_triggers),
    ("macOS CI Bash 3.2 builtins", check_macos_ci_bash32),
    ("Windows ignores Linux QtKeychain .deps", check_windows_ignores_linux_qtkeychain_deps),
    ("Windows About SVG plugin", check_windows_about_svg_plugin),
    ("Windows file dialog plugin", check_windows_file_dialog),
    ("macOS Developer ID notarization", check_macos_notarization),
    ("macOS CI Xcode and Actions majors", check_macos_ci_xcode_and_actions),
    ("macOS SDK compat arch gate", check_macos_sdk_compat),
    ("Homebrew macOS cask", check_homebrew_cask),
)


def main():
    failed = False
    for title, check in CHECKS:
        problems = check()
        if problems:
            failed = True
            print(f"{title}:", file=sys.stderr)
            for problem in problems:
                print(f"  {problem}", file=sys.stderr)
    if failed:
        return 1
    print(f"conventions: {len(CHECKS)} checks passed")
    return 0


if __name__ == "__main__":
    sys.exit(main())
