#!/usr/bin/env bash

# Every asset Panama's desktop names must be something Panama installs.
#
# This is the sibling of declared-dependencies-contract, and it exists because
# that contract has a structural blind spot. It checks commands that scripts
# invoke -- so it reports PASS on a machine where the shell's icon font, the
# pointer theme, the wallpaper and four GUI applications are all missing, because
# none of those are a command in a script.
#
# That was not hypothetical. At the time this was written:
#
#   * change-settings set monospace-font-name to a Nerd Font that no package list
#     installed, and Fonts.qml documents that a mono family without Nerd glyphs
#     replaces every icon in the shell with tofu.
#   * oreo_blue_cursors was named in six files and installed by none.
#   * Wallpaper.qml declared a `shippedPath` that the repository did not ship,
#     and panama-lock fell back to the same non-existent file.
#   * gnome-control-center backed fifteen rows of Panama's own Settings and was
#     declared nowhere, as were gnome-calendar, podman and pipewire-utils.
#
# Every one of those presents as a feature that quietly is not there, which is
# the same failure mode -- and the same argument -- as the dependency contract.

set -uo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"

findings=()
note() { findings+=("$1"); }

declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null \
    | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
[[ -n "$declared" ]] || { printf 'declared assets contract: no package lists found\n' >&2; exit 1; }

is_declared() { grep -qix -- "$1" <<<"$declared"; }

# ── 1. Fonts ─────────────────────────────────────────────────────────────────
#
# A family name is not a package name. Nerd Font packages follow one rule --
# "VictorMono Nerd Font" is victormono-nerd-fonts -- and everything else needs
# naming, so an unmapped family is reported as a real omission rather than
# silently skipped.

font_package() {
    local family="$1"
    # Style and width suffixes are not part of the family that ships.
    family="${family% Mono}"
    family="${family% Propo}"
    family="$(sed -E 's/ (Light|Regular|Medium|SemiBold|Bold|Thin|Black|Italic)$//' <<<"$family")"

    if [[ "$family" == *" Nerd Font" ]]; then
        printf '%s-nerd-fonts' "$(tr '[:upper:]' '[:lower:]' <<<"${family% Nerd Font}" | tr -d ' ')"
        return
    fi
    case "$family" in
        "Adwaita Sans"|"Adwaita Mono") printf 'adwaita-sans-fonts' ;;
        Cantarell)                     printf 'abattis-cantarell-fonts' ;;
        *)                             printf '?%s' "$family" ;;
    esac
}

# Sources are named individually rather than crawled, because a font family is a
# bare string: crawling for one finds every English phrase in the repository.
fonts="$(
    grep -hoE "font-name '[^']+'" "$repo_dir"/setup/scripts/* 2>/dev/null \
        | sed -E "s/.*'(.*)'/\1/"
    grep -hoE '^gtk-font-name=.*' "$repo_dir"/config/dot/gtk-""*/settings.ini.template 2>/dev/null \
        | sed 's/^gtk-font-name=//'
    grep -hoE '^font-family = ".*"' "$repo_dir"/config/dot/ghostty/config 2>/dev/null \
        | sed -E 's/^font-family = "(.*)"/\1/'
    grep -hoE '^\s*font_family = .*' "$repo_dir"/config/dot/hypr/hyprlock.conf.template 2>/dev/null \
        | sed -E 's/^\s*font_family = //'
    grep -hoE 'key: "(interfaceFont|iconFont|monospaceFont|documentFont|applicationFont)", type: "string", def: "[^"]+"' \
        "$repo_dir"/config/dot/quickshell/config/PreferenceSchema.qml 2>/dev/null \
        | sed -E 's/.*def: "([^"]+)".*/\1/'
)"

while IFS= read -r family; do
    [[ -n "$family" ]] || continue
    # Trailing point size, as gsettings and GTK both write it.
    family="$(sed -E 's/ [0-9]+$//' <<<"$family")"
    package="$(font_package "$family")"
    if [[ "$package" == \?* ]]; then
        note "font family '${package#?}' is named in the desktop but maps to no known package"
    elif ! is_declared "$package"; then
        note "font '$family' needs package '$package', which no package list declares"
    fi
done < <(sort -u <<<"$fonts")

# ── 2. Cursor themes ─────────────────────────────────────────────────────────
#
# Satisfied either by a package or by a theme vendored into the repository --
# oreo_blue_cursors is packaged by no Fedora repository, so it ships here.

cursors="$(
    grep -hoE "cursor-theme '[^']+'" "$repo_dir"/setup/scripts/* 2>/dev/null \
        | sed -E "s/.*'(.*)'/\1/"
    grep -hoE '^gtk-cursor-theme-name=.*' "$repo_dir"/config/dot/gtk-""*/settings.ini.template 2>/dev/null \
        | sed 's/^gtk-cursor-theme-name=//'
    grep -hoE '^export XCURSOR_THEME=.*' "$repo_dir"/config/dot/uwsm/env 2>/dev/null \
        | sed 's/^export XCURSOR_THEME=//'
    grep -hoE 'key: "cursorTheme", type: "string", def: "[^"]+"' \
        "$repo_dir"/config/dot/quickshell/config/PreferenceSchema.qml 2>/dev/null \
        | sed -E 's/.*def: "([^"]+)".*/\1/'
)"

while IFS= read -r theme; do
    [[ -n "$theme" ]] || continue
    [[ -d "$repo_dir/config/local/share/icons/$theme/cursors" ]] && continue
    is_declared "${theme%%_*}-cursor-theme" && continue
    is_declared "$theme" && continue
    note "cursor theme '$theme' is named in the desktop but is neither vendored nor packaged"
done < <(sort -u <<<"$cursors")

# ── 3. The shipped wallpaper ─────────────────────────────────────────────────
#
# Wallpaper.qml calls one path `shippedPath` and panama-lock falls back to it.
# A fallback that does not exist is not a fallback.

while IFS= read -r image; do
    [[ -n "$image" ]] || continue
    [[ -f "$repo_dir/config/wallpapers/$image" ]] \
        || note "wallpaper '$image' is referenced as shipped, but config/wallpapers/$image does not exist"
done < <(grep -rhoE 'Pictures/Wallpapers/[A-Za-z0-9._-]+\.(jpg|jpeg|png|webp)' \
    "$repo_dir"/config/dot/quickshell/services "$repo_dir"/config/dot/quickshell/scripts \
    "$repo_dir"/config/dot/hypr 2>/dev/null | sed 's|.*/||' | sort -u)

# ── 3b. Shipping is not installing ───────────────────────────────────────────
#
# A vendored asset that link-dotfiles never puts into place is exactly as absent
# as one that was never vendored -- and it would satisfy the two checks above,
# which only prove the repository contains the file. Both directories must
# actually be consumed by the installer.

for vendored in config/local/share/icons config/wallpapers; do
    [[ -e "$repo_dir/$vendored" ]] || continue
    grep -qF "$vendored" "$repo_dir/setup/scripts/link-dotfiles" 2>/dev/null \
        || note "$vendored is shipped but link-dotfiles never installs it, so a fresh machine still does without"
done

# ── 4. Commands the shell launches ───────────────────────────────────────────
#
# The dependency contract reads scripts. These are launched from QML, so it
# never sees them -- which is how the application backing fifteen Settings rows
# went undeclared.

qml_package() {
    case "$1" in
        hyprctl)            printf 'hyprland' ;;
        nmcli)              printf 'NetworkManager' ;;
        wl-copy|wl-paste)   printf 'wl-clipboard' ;;
        xdg-open)           printf 'xdg-utils' ;;
        pw-dump|pw-play)    printf 'pipewire-utils' ;;
        *)                  printf '%s' "$1" ;;
    esac
}

# Provided by the base system or the shell itself; nothing installs these
# separately, and listing them would be noise.
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'

while IFS= read -r command_name; do
    [[ -n "$command_name" ]] || continue
    [[ "$command_name" =~ $QML_BASELINE ]] && continue
    package="$(qml_package "$command_name")"
    is_declared "$package" \
        || note "the shell launches '$command_name' from QML, but '$package' is undeclared"
done < <(grep -rhoE '(command: |execDetached\(|command = )\["[a-z][a-z0-9._-]*"' \
    "$repo_dir"/config/dot/quickshell --include='*.qml' 2>/dev/null \
    | sed -E 's/.*\["([^"]*)"/\1/' | sort -u)

# ── 5. Directories nothing puts into service ─────────────────────────────────
#
# A config directory nobody links is a directory nobody has. This catches the
# case where a new dot/ directory is added and link-dotfiles' `dirs` array is not
# updated, which fails silently -- the config is simply never in place.
#
# Linking is not the only way to be used: ohmyposh is loaded straight out of the
# repository by config/bash/shell, which needs no symlink at all. So the shell
# configuration counts as a consumer too, and only a directory nothing at all
# refers to is reported.

consumers=("$repo_dir/setup/scripts/link-dotfiles" "$repo_dir"/config/bash/*)
for dot_dir in "$repo_dir"/config/dot/*/; do
    name="$(basename "$dot_dir")"
    grep -qE "(^|[^A-Za-z0-9_-])$name([^A-Za-z0-9_-]|$)" "${consumers[@]}" 2>/dev/null \
        || note "config/dot/$name is referenced by neither link-dotfiles nor the shell configuration, so nothing puts it into service"
done

# ── 6. Names in the array with nothing behind them ───────────────────────────
#
# The inverse of the check above, and the one that bites when a directory is
# removed rather than added: an entry left in `dirs` makes link-dotfiles point
# ~/.config/<name> at a path that does not exist, and a dangling symlink is
# worse than no symlink, because everything that looks there finds something.
#
# Missed the first time this contract was written. Deleting config/dot/forge in
# the GNOME excision was caught by nothing until the array entry was removed too,
# which was luck rather than a test.

mapfile -t linked_dirs < <(sed -n '/^dirs=(/,/)/p' "$repo_dir/setup/scripts/link-dotfiles" \
    | grep -oE '"[^"]+"' | tr -d '"')

(( ${#linked_dirs[@]} > 0 )) \
    || note "link-dotfiles' dirs array could not be read, so nothing checked what it links"

for name in "${linked_dirs[@]}"; do
    [[ -d "$repo_dir/config/dot/$name" ]] \
        || note "link-dotfiles links $name, but config/dot/$name does not exist -- that is a dangling symlink in ~/.config"
done

# ── Report ───────────────────────────────────────────────────────────────────

# One asset named by three different files is one finding, not three.
if (( ${#findings[@]} > 0 )); then
    mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
    printf 'declared assets contract: %d finding(s)\n' "${#findings[@]}" >&2
    printf '  - %s\n' "${findings[@]}" >&2
    exit 1
fi

printf 'declared assets contract: PASS\n'
