#!/bin/sh
# script/install: write the current policy files from the prompts repo
# into a repo clone, over HTTP. It commits nothing; script/update-policy
# does that. Identical in all repos. Our own extension to
# scripts-to-rule-them-all.
#
# Usage: script/install [path of a repo clone]
# Without a path it updates the repo this script sits in.
#
# Standard output is the full hash of the prompts commit, then each path
# written, one per line. Messages go to standard error.
#
# 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 has never held 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 has never
#     held stops the script, unless it is blank or a comment.
set -eu

PROMPTS_URL="https://git.eeqj.de/sneak/prompts"
PROMPTS_API_URL="https://git.eeqj.de/api/v1/repos/sneak/prompts"
PROMPTS_FIRST_COMMIT="af257bef63f162a681bddc80d9b941c47a44b748"

# Paths in the prompts repo. A file the target repo does not carry is
# skipped, except the two scripts, which every repo gets.
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
script/install
script/update-policy
"

die() {
    echo "install: $*" >&2
    exit 1
}

# fetch <url> <output file>
fetch() {
    curl --fail --silent --show-error --max-time 60 --output "$2" "$1" ||
        die "cannot fetch $1"
}

# local_path <path in prompts>: print where the target repo keeps the
# file, or nothing if git does not track it there. 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
}

# former_lines <patch> <path in prompts>: print the lines earlier commits
# removed from the file. The file's section of the patch ends at the
# next diff or the next commit.
former_lines() {
    awk -v header="diff --git a/$2 b/$2" '
        /^diff --git / { inside = ($0 == header); next }
        /^From / { inside = 0; next }
        inside && /^-/ { print substr($0, 2) }
    ' "$1"
}

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

# keep_lines_below_last <fetched> <local>: append to the fetched file
# whatever follows its last line in the local file. Stop if the canonical
# file has never held a line above that one, 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 that neither the
# result nor the canonical file has ever held, 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 has never held; only the deny list of the test-support rule may differ from canonical:
$own"
    fi
}

main() {
    [ "$#" -le 1 ] || die "usage: script/install [path of a repo clone]"

    target="${1:-$(dirname "$0")/..}"
    cd "$target" || die "no such directory: $target"
    if [ "$(git rev-parse --show-toplevel 2> /dev/null)" != "$(pwd -P)" ]; then
        die "not the top of a git clone: $target"
    fi
    if [ -e prompts/REPO_POLICIES.md ]; then
        die "this repo is the source of the policy files"
    fi

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

    fetch "$PROMPTS_API_URL/branches/main" "$tmp/branch.json"
    commit="$(grep -Eo '"id":"[0-9a-f]{40}"' "$tmp/branch.json" |
        head -n 1 | cut -d'"' -f4)"
    [ -n "$commit" ] || die "no commit hash in $PROMPTS_API_URL/branches/main"

    fetch "$PROMPTS_URL/compare/$PROMPTS_FIRST_COMMIT...$commit.patch" \
        "$tmp/history.patch"

    carried=""
    for file in $VENDORED_FILES; do
        case "$file" in
            script/*) path="$file" ;;
            *) path="$(local_path "$file")" ;;
        esac
        [ -n "$path" ] || continue
        if [ -n "$(git status --porcelain -- "$path")" ]; then
            die "$path has uncommitted changes"
        fi
        mkdir -p "$tmp/$(dirname "$path")"
        fetch "$PROMPTS_URL/raw/commit/$commit/$file" "$tmp/$path"
        former_lines "$tmp/history.patch" "$file" > "$tmp/$path.former"
        carried="$carried $path"
    done

    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

    echo "$commit"
    for path in $carried; do
        case "$path" in
            script/*)
                # A new name and mv, because script/update-policy may
                # be the script running.
                mkdir -p script
                cp "$tmp/$path" "$path.new"
                chmod 755 "$path.new"
                mv "$path.new" "$path"
                ;;
            *)
                cp "$tmp/$path" "$path"
                ;;
        esac
        echo "$path"
    done
}

main "$@"
