#!/bin/zsh
# Open today's note, reconciling it with the server around the edit.
#
# The server rolls the note forward at 00:01, so normally this fetches a file
# that already exists. Everything is arranged so that losing the network
# degrades to editing the local copy, never to losing an edit.
#
# Alongside the note, a pristine copy of the text last agreed with the server is
# kept, with its ETag. Without it there is no way to tell "I have unsaved edits"
# from "I am simply behind", and the second case gets misreported as the first
# every time the phone pushes.

emulate -L zsh
set -o errexit -o nounset -o pipefail

NOTES=${TODO_NOTES:-$HOME/docs/notes/todo}
STATE=${TODO_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/todo}
REMOTE=${TODO_REMOTE:-https://files.rcjohnstone.com/docs/notes/todo}
DAV_USER=${TODO_USER:-connor}
RBW_ENTRY=${TODO_RBW_ENTRY:-SFTPGo}
PRUNE=${TODO_PRUNE:-$HOME/.local/lib/todo/prune.py}
# zshenv exports EDITOR on every invocation, including this script's own, so
# `EDITOR=x todo` cannot work. TODO_EDITOR is the override that does.
EDIT=${TODO_EDITOR:-${EDITOR:-nvim}}
AGENT=todo-cli/2.0

today=$(date --iso-8601)
current=$NOTES/$today.md
base=$STATE/base/$today.md
base_etag=$STATE/base/$today.etag

mkdir -p $NOTES $STATE/base
work=$(mktemp -d); trap 'rm -rf $work' EXIT

note() { print -u2 -- "todo: $*" }
die()  { print -u2 -- "todo: $*"; exit 1 }

# Password stays in a variable and reaches curl over stdin. Passing it as -u
# would put it in argv, where any other user's `ps` can read it.
dav_pass=""
if command -v rbw >/dev/null 2>&1; then
    dav_pass=$(rbw get $RBW_ENTRY 2>/dev/null) || dav_pass=""
fi

dav() {
    [[ -n $dav_pass ]] || return 64
    printf 'user = "%s:%s"\n' $DAV_USER $dav_pass |
        curl -sS --config - -A $AGENT --max-time 15 "$@"
}

etag_of() { grep -i '^etag:' $1 2>/dev/null | tr -d '\r' | awk '{print $2}' }

adopt() {  # text-file etag
    [[ $1 == $current ]] || cp $1 $current
    cp $1 $base
    print -r -- ${2:-} > $base_etag
}

set_aside() {  # text-file -> prints the filename used
    local name=$today.sync-conflict-$(date +%Y%m%d-%H%M%S)-${HOST:u}.md
    cp $1 $NOTES/$name
    dav -X PUT --data-binary @$1 -o /dev/null $REMOTE/$name >/dev/null 2>&1 || true
    print -r -- $name
}

# ---------------------------------------------------------------- pull

remote_hash=""

if [[ -z $dav_pass ]]; then
    note "no credential from rbw, working offline"
else
    code=$(dav -o $work/theirs -D $work/h -w '%{http_code}' $REMOTE/$today.md) || code=000
    case $code in
        200)
            their_etag=$(etag_of $work/h)
            remote_hash=$(md5sum < $work/theirs)
            if [[ ! -e $current ]]; then
                adopt $work/theirs $their_etag
            elif [[ ! -e $base ]]; then
                # No record of a previous agreement, so nothing to merge from.
                if cmp -s $current $work/theirs; then
                    adopt $work/theirs $their_etag
                else
                    note "no sync history, yours saved as $(set_aside $current)"
                    adopt $work/theirs $their_etag
                fi
            elif [[ -e $base_etag && -n $their_etag && $their_etag == $(<$base_etag) ]]; then
                : # server has not moved since the last agreement
            elif git merge-file -p $current $base $work/theirs > $work/merged 2>/dev/null; then
                # git rather than diff3. diff3 calls it a conflict even when
                # both sides made the same change, and both devices ticking the
                # same box is an ordinary thing to happen here.
                cp $work/merged $current
                if cmp -s $current $work/theirs; then
                    # Nothing local to contribute, so this is the new agreement.
                    adopt $work/theirs $their_etag
                else
                    note "merged in changes from the server"
                fi
            else
                # Both sides changed the same lines. The server's copy becomes
                # the note and the local one goes beside it, which is the
                # convention already in this directory.
                note "conflict, yours saved as $(set_aside $current)"
                adopt $work/theirs $their_etag
            fi
            ;;
        404)
            # The 00:01 timer has not run, or the clock disagrees about the day.
            # One create, never a poll loop: repeated 404s trip http-probing.
            note "no $today.md on the server yet, creating it here"
            ;;
        401)
            # Never retry: repeated 401s from one IP trip http-generic-bf and
            # earn a four hour ban that no whitelist covers.
            die "credentials rejected. fix the $RBW_ENTRY entry, do not re-run blindly"
            ;;
        000) note "server unreachable, working offline" ;;
        *)   note "server returned $code, working offline" ;;
    esac
fi

# ------------------------------------------------------- local fallback

if [[ ! -e $current ]]; then
    # Match the date glob exactly. A bare `ls | tail -1` can select a
    # .sync-conflict- sibling, which is how divergent notes spread before.
    previous=($NOTES/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].md(N))
    if (( ${#previous} )); then
        src=${previous[-1]}
        if [[ -x $PRUNE ]]; then
            $PRUNE $src > $work/new
        else
            note "prune not found at $PRUNE, carrying the note forward unpruned"
            cp $src $work/new
        fi
        # Only line 1. A global replace would rewrite any date in the body, so
        # "meeting moved to 2026-09-08" would silently become today.
        sed -i "1s/.*/# $today/" $work/new
        cp $work/new $current
        note "created $today.md from ${src:t}"
    else
        printf '# %s\n' $today > $current
        note "started a fresh $today.md"
    fi
fi

# ---------------------------------------------------------------- edit

$EDIT $current

# ---------------------------------------------------------------- push

[[ $(md5sum < $current) == $remote_hash ]] && { note "in step with the server"; exit 0 }
[[ -n $dav_pass ]] || { note "no credential, leaving changes local"; exit 0 }

code=$(dav -X PUT --data-binary @$current -o /dev/null -w '%{http_code}' $REMOTE/$today.md) ||
    code=000
case $code in
    200|201|204)
        # The ETag a PUT returns is not the file's own, so it is read back
        # rather than believed. See FINDINGS.md.
        dav -I -D $work/ph -o /dev/null $REMOTE/$today.md >/dev/null 2>&1 || true
        adopt $current "$(etag_of $work/ph)"
        note "pushed $today.md"
        ;;
    401) die "credentials rejected on push. changes are safe locally" ;;
    000) note "push failed, server unreachable. changes are safe locally" ;;
    *)   note "push returned $code. changes are safe locally" ;;
esac
