#!/usr/bin/env bash
#
# Shared release helpers for install.sh and update.sh.
#
# Two deployment modes, and the difference matters:
#
#   release  — pinned to an immutable tag, checked out detached. What a
#              production server should be on: it moves only when someone
#              decides it moves.
#   branch   — following the head of a line, usually main. The edge. CI tags a
#              commit AFTER it is pushed, so origin/main is briefly, and after a
#              failure permanently, code nobody has verified.
#
# The mode is persisted, because update.sh cannot infer it: a detached checkout
# and a branch checkout need entirely different git commands, and guessing wrong
# either fails loudly or silently drags a pinned server onto the edge.

MODE_FILE="storage/app/deploy-mode"
MANIFEST_FILE="storage/app/deployment.json"

# The release number the checkout claims to be.
release_version() { tr -d ' \n\r' < VERSION 2>/dev/null || echo '0.0.0'; }

# release_mode — "release" or "branch"; defaults to branch for installs that
# predate this file.
release_mode() { cat "$MODE_FILE" 2>/dev/null || echo 'branch'; }

release_source() { cat "${MODE_FILE}.source" 2>/dev/null || echo ''; }

# The commit the manifest claims is deployed. Read with sed rather than a JSON
# parser so this has no dependency the installer does not already guarantee.
release_manifest_commit() {
    sed -n 's/.*"commit"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1
}

# The version the manifest says actually DEPLOYED, which is not the same as the
# VERSION file: an update moves the checkout before it migrates, so a run that
# failed in between leaves a newer number on disk than is serving. Deciding
# "is there something newer" against the file would then offer nothing, because
# the checkout already claims to be the version it never finished installing.
release_manifest_version() {
    sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_FILE" 2>/dev/null | head -1
}

# release_version_gt A B — true when A is strictly a higher version than B.
#
# By version, not by ancestry: a tag can be cut from anywhere, and what an
# operator is being offered is a version number. `sort -V` does the comparing,
# so v1.10.0 correctly beats v1.9.0 where a string comparison would not.
release_version_gt() {
    [[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]]
}

# release_newest_tag — the highest v* tag by version order, or nothing.
#
# Deliberately not `git tag … | head -1`. head exits after the first line, git
# gets SIGPIPE, and under `set -o pipefail` that non-zero status leaves the
# command substitution and ends the caller — the same way the tag count did,
# and the same trap install-agent.sh already carries a warning about. A read
# loop over a process substitution is not a pipeline and cannot do it.
release_newest_tag() {
    local tag
    while read -r tag; do
        [[ -n "$tag" ]] || continue
        printf '%s' "$tag"
        return 0
    done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null)

    return 0
}

# release_tags_ahead VERSION — how many v* tags are higher than VERSION.
#
# Lives here rather than inline in the agent because it has to be testable. The
# inline version killed the update agent on every tick: the loop body was
# `release_version_gt … && echo`, so the LAST iteration — almost always a tag
# that is not newer — left the loop with a non-zero status, `pipefail` carried
# it out of the pipeline, the command substitution inherited it, and `set -e`
# ended the agent before it could write a status. The console then showed a
# check that never finished and a last-checked time frozen at the deployment.
#
# `if` rather than `&&` is the whole fix: an if whose condition is false still
# returns 0. Anything added here must keep that property.
release_tags_ahead() {
    local current="$1" tag count=0

    while read -r tag; do
        [[ -n "$tag" ]] || continue
        if release_version_gt "${tag#v}" "$current"; then
            count=$((count + 1))
        fi
    done < <(git tag --list 'v*' --sort=-v:refname 2>/dev/null)

    printf '%s' "$count"
}

release_remember() { # release_remember MODE SOURCE
    mkdir -p "$(dirname "$MODE_FILE")"
    printf '%s' "$1" > "$MODE_FILE"
    printf '%s' "$2" > "${MODE_FILE}.source"
}

# release_would_go_backwards BASE TARGET — true when TARGET is an ancestor of
# BASE, i.e. this is a move back in history.
#
# Ancestry, not version strings: SemVer order and git history are different
# things, and a tag can be cut from anywhere. Ancestry is what actually says
# whether the code is going backwards.
release_would_go_backwards() {
    local base="$1" target="$2"
    [[ "$base" == "$target" ]] && return 1
    git merge-base --is-ancestor "$target" "$base" 2>/dev/null
}

# release_write_manifest COMMIT SOURCE MODE
#
# Written only once every step has succeeded, and written atomically: a
# half-written manifest read by the console would report a version that never
# ran. The console reads this rather than live git, because git says what the
# files are, not whether the deployment finished.
release_write_manifest() {
    local commit="$1" source="$2" mode="$3" tmp
    mkdir -p "$(dirname "$MANIFEST_FILE")"
    tmp="$(mktemp "${MANIFEST_FILE}.XXXXXX")"

    printf '{\n  "version": "%s",\n  "commit": "%s",\n  "source": "%s",\n  "mode": "%s",\n  "deployed_at": "%s"\n}\n' \
        "$(json_escape "$(release_version)")" \
        "$(json_escape "$commit")" \
        "$(json_escape "$source")" \
        "$(json_escape "$mode")" \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp"

    chmod 644 "$tmp"
    mv -f "$tmp" "$MANIFEST_FILE"
}

# json_escape STRING — a branch name is chosen by whoever made the branch, and
# a quote or a backslash in one would produce a document the console reads as
# absent: it would lose the deployment metadata after a deployment that
# otherwise went perfectly. No jq dependency, because this has to work on a
# server installed before jq was ever on the package list.
json_escape() {
    local s="$1"
    s="${s//\\/\\\\}"
    s="${s//\"/\\\"}"
    # Control characters cannot occur in a git ref and cannot be represented
    # raw; dropping them beats emitting a broken document.
    printf '%s' "$s" | tr -d '\000-\037'
}
