#!/bin/sh
# script/update-policy: replace this repo's vendored policy files with
# the canonical ones from the prompts repo, commit them and push.
# Identical in all repos. Our own extension to
# scripts-to-rule-them-all.
#
# Usage: script/update-policy [full commit hash of prompts]
# Without a hash it uses the head of the prompts main branch.
#
# The script writes nothing until every file has been fetched and
# merged. A repo's own entries are kept:
#   - .gitignore, .editorconfig, .dockerignore: the lines below the
#     canonical file's last line. An entry above that line which the
#     canonical file lacks stops the script.
#   - .golangci.yml: the entries the repo added to the deny list of the
#     test-support rule. Any other line the canonical file lacks stops
#     the script, unless it is blank or a comment.
set -eu

ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PROMPTS_URL="https://git.eeqj.de/sneak/prompts"

# Paths in the prompts repo. A file this repo does not carry is skipped.
VENDORED_FILES="
.dockerignore
.editorconfig
.gitea/workflows/check.yml
.gitignore
.golangci.yml
.prettierrc
prompts/API_POLICIES.md
prompts/CODE_STYLEGUIDE.md
prompts/CODE_STYLEGUIDE_GO.md
prompts/CODE_STYLEGUIDE_JS.md
prompts/CODE_STYLEGUIDE_PYTHON.md
prompts/EXISTING_REPO_CHECKLIST.md
prompts/GO_HTTP_SERVER_CONVENTIONS.md
prompts/LLM_PROSE_TELLS.md
prompts/NEW_REPO_CHECKLIST.md
prompts/REPO_POLICIES.md
"

die() {
    echo "update-policy: $*" >&2
    exit 1
}

# local_path <path in prompts>: print where this repo keeps the file, or
# nothing if git does not track it here. A prompts/NAME.md lands at
# docs/NAME.md or NAME.md.
local_path() {
    name="${1#prompts/}"
    if [ "$name" != "$1" ] && [ -n "$(git ls-files -- "docs/$name")" ]; then
        echo "docs/$name"
    elif [ -n "$(git ls-files -- "$name")" ]; then
        echo "$name"
    fi
}

# local_only_lines <fetched> <local> [end]: print each line of the local
# file, above line end if given, that the fetched file lacks. Blank
# lines and comments do not count.
local_only_lines() {
    awk -v end="${3:-}" '
        NR == FNR { fetched[$0]; next }
        (end == "" || FNR < end) && !/^[ \t]*(#|$)/ && !($0 in fetched)
    ' "$1" "$2"
}

# keep_lines_below_last <fetched> <local>: append to the fetched file
# whatever follows its last line in the local file. Stop if a line above
# that one is missing from the fetched file, unless it is blank or a
# comment: replacing the file would delete it.
keep_lines_below_last() {
    last="$(tail -n 1 "$1")"
    number="$(grep -n -F -x -- "$last" "$2" | head -n 1 | cut -d: -f1)"
    if [ -z "$last" ] || [ -z "$number" ]; then
        die "$2 lacks this line; add it and put the repo's own entries below it: $last"
    fi
    own="$(local_only_lines "$1" "$2" "$number")"
    if [ -n "$own" ]; then
        die "$2 has entries above its last canonical line that the canonical file lacks; move each below that line to keep it, or delete it:
$own"
    fi
    tail -n "+$((number + 1))" "$2" >> "$1"
}

# keep_deny_entries <fetched> <local>: add to the deny list of the
# test-support rule in the fetched file the entries only the local file
# has. An entry is a "- pkg:" line, the lines under it and any comment
# above it. Stop if the local file still has a line the result lacks,
# unless it is blank or a comment: replacing the file would delete it.
keep_deny_entries() {
    awk '
        function indent(line) { return match(line, /[^ ]/) - 1 }
        function print_local_entries(i) {
            for (i = 1; i <= count; i++)
                if (!(pkg[i] in fetched_pkg))
                    printf "%s", entry[i]
        }

        FNR == 1 { in_rule = 0; in_list = 0 }

        in_list && /[^ ]/ && indent($0) <= list_indent {
            in_list = 0
            if (FILENAME == ARGV[2]) print_local_entries()
        }

        in_list {
            is_entry = ($1 == "-" && $2 == "pkg:")
            if (FILENAME == ARGV[2]) {
                if (is_entry) fetched_pkg[$3]
                print
            } else if (is_entry) {
                count++
                pkg[count] = $3
                entry[count] = comment $0 "\n"
                comment = ""
            } else if (/^ *#/) {
                comment = comment $0 "\n"
            } else if (count) {
                entry[count] = entry[count] $0 "\n"
            }
            next
        }

        in_rule && /^ *[^ #]/ && indent($0) <= rule_indent { in_rule = 0 }
        /^ *test-support:/ { in_rule = 1; rule_indent = indent($0) }
        in_rule && /^ *deny:/ {
            in_rule = 0
            in_list = 1
            list_indent = indent($0)
            if (FILENAME == ARGV[2]) found = 1
        }
        FILENAME == ARGV[2] { print }

        END {
            if (in_list) print_local_entries()
            exit !found
        }
    ' "$2" "$1" > "$1.merged" ||
        die "the fetched $2 has no deny list in its test-support rule"
    mv "$1.merged" "$1"
    own="$(local_only_lines "$1" "$2")"
    if [ -n "$own" ]; then
        die "$2 has lines the canonical file lacks; add each to the canonical file to keep it, or delete it:
$own"
    fi
}

main() {
    cd "$ROOT"

    [ "$#" -le 1 ] || die "usage: script/update-policy [full commit hash]"

    project="$(script/projectname)"
    if [ "$project" = "prompts" ]; then
        die "this repo is the source of the policy files"
    fi

    commit="${1:-}"
    if [ -z "$commit" ]; then
        head_line="$(git ls-remote "$PROMPTS_URL.git" refs/heads/main)"
        commit="${head_line%%[[:space:]]*}"
    fi
    printf '%s\n' "$commit" | grep -Eqx '[0-9a-f]{40}' ||
        die "not a full commit hash: '$commit'"

    tmp="$(mktemp -d)"
    trap 'rm -rf "$tmp"' EXIT

    carried=""
    for file in $VENDORED_FILES; do
        path="$(local_path "$file")"
        [ -n "$path" ] || continue
        if [ -n "$(git status --porcelain -- "$path")" ]; then
            die "$path has uncommitted changes"
        fi
        mkdir -p "$tmp/$(dirname "$path")"
        curl --fail --silent --show-error --max-time 60 \
            --output "$tmp/$path" \
            "$PROMPTS_URL/raw/commit/$commit/$file" ||
            die "cannot fetch $file at $commit"
        carried="$carried $path"
    done
    [ -n "$carried" ] || die "this repo carries none of the vendored files"

    for path in $carried; do
        case "$path" in
            .gitignore | .editorconfig | .dockerignore)
                keep_lines_below_last "$tmp/$path" "$path"
                ;;
            .golangci.yml)
                keep_deny_entries "$tmp/$path" "$path"
                ;;
        esac
    done

    for path in $carried; do
        cp "$tmp/$path" "$path"
        git add -- "$path"
    done

    if git diff --cached --quiet -- $carried; then
        echo "update-policy: already at prompts $commit, nothing to commit"
    else
        git commit -m "Update policy files from prompts $commit" -- $carried
    fi

    # Also pushes when there was nothing to commit, so a run after a
    # failed push delivers the commit that push left behind.
    git push origin HEAD || die "push failed; fix that and run this again"
}

main "$@"
