#!/bin/bash
# ClaudeTools Bidirectional Sync Script
# Ensures proper pull BEFORE push on all machines
# Prints incoming/outgoing change summary with author attribution

set -e

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

# --- Arg parsing -------------------------------------------------------------
# Submodule advance (fetch + checkout main + merge --ff-only origin/main) is
# expensive (full network fetch of every submodule) and is normally wasted work:
# per CLAUDE.md the guru-rmm pinned commit lagging `main` is EXPECTED, not stale.
# So it is opt-in. Default OFF. Enable with `--with-submodules` / `--submodules`
# on the command line, or `SYNC_SUBMODULES=1` in the environment. Fresh-clone
# init/populate (Phase 1a) always runs regardless — only the advance is gated.
ADVANCE_SUBMODULES=0
[ "${SYNC_SUBMODULES:-0}" = "1" ] && ADVANCE_SUBMODULES=1
for arg in "$@"; do
    case "$arg" in
        --with-submodules|--submodules) ADVANCE_SUBMODULES=1 ;;
    esac
done

# --- Guard: strip Windows path-as-filename cruft before staging -------------
# Windows machines occasionally drop files literally named like "C:\path" or
# ".claude\current-mode" into the repo. The illegal ':' / '\' chars get stored
# as Unicode Private-Use-Area substitutes (U+F03A, U+F00A, ...). A blind
# `git add -A` would stage and propagate them to Gitea. This removes any such
# UNTRACKED path before staging. Only untracked files are touched — tracked
# content is never auto-deleted (use `git rm` for that, deliberately).
purge_garbled_paths() {
    local rec st p removed=0
    # POSIX ERE (not PCRE): `grep -P` refuses to run under non-UTF-8/unibyte locales on
    # some platforms (Git Bash printed "grep: -P supports only unibyte and UTF-8 locales",
    # silently disabling this guard). ERE has no such restriction; LC_ALL=C makes the match
    # byte-wise. Same byte set as before: control chars / backslash / colon, plus the MSYS2
    # Private-Use-Area substitutes (0xEE 0x80-0xBF = U+E0xx, 0xEF 0x80-0xA3 = U+F0xx) that
    # stand in for : \ newline etc. in Windows path-as-filename cruft.
    local garble_re=$'[\001-\037\\:]|\356[\200-\277]|\357[\200-\243]'
    while IFS= read -r -d '' rec; do
        st="${rec:0:2}"
        p="${rec:3}"
        [ -z "$p" ] && continue
        [ "$st" = "??" ] || continue   # untracked only
        if printf '%s' "$p" | LC_ALL=C grep -qaE "$garble_re"; then
            echo -e "${YELLOW}[WARNING]${NC} Removing garbled untracked path before staging: $(printf '%s' "$p" | cat -v)"
            rm -f -- "$p" 2>/dev/null || true
            removed=1
        fi
    done < <(git status --porcelain -z)
    [ "$removed" = 1 ] && echo -e "${YELLOW}[WARNING]${NC} Garbled Windows path-as-filename cruft removed (not synced)."
    return 0
}

# --- Attribution: force commit authorship to match identity.json -------------
# Commit author is the per-person source of truth and must NEVER ride on stale
# local `git config`. If config disagrees with identity.json, correct the LOCAL
# repo config (not --global) so this machine's commits are always attributed to
# the human who actually owns this identity. Call once per repo (claudetools,
# then vault) before any commit happens.
reconcile_git_identity() {
    local want_name="$1" want_email="$2" cur
    # Bot-context override: when invoked by the Discord bot, attribute the COMMIT
    # to the human who requested it (git AUTHOR = mapped requester from users.json)
    # with "ClaudeTools Bot" as the COMMITTER. Unmapped/unknown requester falls
    # back to bot-as-author. Strict no-op when CLAUDETOOLS_ACTOR is unset, so
    # interactive sessions keep identity.json attribution.
    if [ "${CLAUDETOOLS_ACTOR:-}" = "discord-bot" ]; then
        local _bot_id
        _bot_id=$("${PYTHON:-python}" - "$REPO_ROOT/.claude/users.json" "${CLAUDETOOLS_REQUESTER_USER:-}" <<'BOTID'
import json, sys
usersp, ukey = sys.argv[1], sys.argv[2]
name, email = "ClaudeTools Bot", "bot@azcomputerguru.com"
if ukey:
    try:
        u = json.load(open(usersp))["users"].get(ukey, {})
        name = u.get("git_name") or u.get("full_name") or name
        email = u.get("git_email") or u.get("email") or email
    except Exception:
        pass
print(name + "|" + email)
BOTID
)
        want_name="${_bot_id%%|*}"
        want_email="${_bot_id##*|}"
        export GIT_COMMITTER_NAME="ClaudeTools Bot"
        export GIT_COMMITTER_EMAIL="bot@azcomputerguru.com"
    fi
    if [ -n "$want_name" ]; then
        cur=$(git config user.name 2>/dev/null || true)
        if [ "$cur" != "$want_name" ]; then
            echo -e "${YELLOW}[WARNING]${NC} git user.name '${cur}' -> '${want_name}' (corrected to match identity.json)"
            git config user.name "$want_name"
        fi
    fi
    if [ -n "$want_email" ]; then
        cur=$(git config user.email 2>/dev/null || true)
        if [ "$cur" != "$want_email" ]; then
            echo -e "${YELLOW}[WARNING]${NC} git user.email '${cur}' -> '${want_email}' (corrected to match identity.json)"
            git config user.email "$want_email"
        fi
    fi
    return 0
}

# When the parent's gitlinks point a submodule at a commit that TRACKS a file the
# submodule currently holds as UNTRACKED, `git submodule update` aborts with
# "untracked working tree files would be overwritten by checkout". Non-fatal, but it
# leaves the pointer stale and recurs every sync (seen with guru-rmm
# docs/RMM_THOUGHTS.md). Resolve NON-DESTRUCTIVELY: for each submodule, find untracked
# files that collide with the target gitlink commit's tree and rename them aside
# (content preserved) so checkout can proceed. Returns 0 if it moved anything (caller
# should retry the update), 1 if there was nothing to do.
resolve_submodule_collisions() {
    [ -f ".gitmodules" ] || return 1
    local moved=0 subpath target untracked stamp dest
    local subpaths=()
    stamp=$(date -u "+%Y%m%dT%H%M%SZ")
    while read -r p; do [ -n "$p" ] && subpaths+=("$p"); done \
        < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' 2>/dev/null | awk '{print $2}')
    for subpath in "${subpaths[@]}"; do
        [ -e "$subpath/.git" ] || continue
        # Target gitlink commit the parent now expects for this submodule.
        target=$(git ls-tree HEAD -- "$subpath" 2>/dev/null | awk '$2=="commit"{print $3}')
        [ -n "$target" ] || continue
        # Need the target commit present locally to know which paths it tracks.
        git -C "$subpath" cat-file -e "$target" 2>/dev/null || continue
        # Untracked files in the submodule that the target commit also tracks = the
        # exact set that would block checkout. Move only those aside.
        while IFS= read -r -d '' untracked; do
            git -C "$subpath" cat-file -e "${target}:${untracked}" 2>/dev/null || continue
            dest="${untracked}.synced-aside-${stamp}"
            if mv "$subpath/$untracked" "$subpath/$dest" 2>/dev/null; then
                echo -e "${YELLOW}[WARNING]${NC} ${subpath}: untracked '${untracked}' collided with incoming commit; preserved as '${dest}' (reconcile manually if it held local edits)."
                moved=1
            fi
        done < <(git -C "$subpath" ls-files --others --exclude-standard -z 2>/dev/null)
    done
    [ "$moved" -eq 1 ] && return 0 || return 1
}

# Advance submodules to the parent's pinned gitlinks after a parent pull, WITHOUT
# ever clobbering a submodule that has local work. The pristine/pinned state of a
# submodule is a CLEAN, DETACHED HEAD; a developer actively working in a submodule
# checks out a BRANCH and/or has uncommitted edits. Those we skip entirely so an
# in-progress feature inside a submodule survives a parent sync.
#
# (Friction 2026-06-22: an unguarded `git submodule update --init --recursive`
# after the parent rebase repeatedly reset guru-rmm to its pinned commit — detaching
# HEAD and discarding a pushed-elsewhere feature branch + commits mid-build. The
# Phase-1a init guard at the top did not cover this post-rebase reconcile path.)
#
# Protected-submodule notices go to stderr; git's own output goes to stdout so the
# caller can capture it for failure reporting. Returns git's rc (0 if nothing to do).
submodule_update_safe() {
    [ -f ".gitmodules" ] || return 0
    local p safe=() prot=()
    while read -r p; do
        [ -n "$p" ] || continue
        if [ -e "$p/.git" ]; then
            # On a branch (not the pristine detached pin) -> someone is working here.
            if git -C "$p" symbolic-ref -q HEAD >/dev/null 2>&1; then
                prot+=("$p [branch $(git -C "$p" rev-parse --abbrev-ref HEAD 2>/dev/null)]")
                continue
            fi
            # Uncommitted changes -> protect.
            if [ -n "$(git -C "$p" status --porcelain 2>/dev/null)" ]; then
                prot+=("$p [uncommitted changes]")
                continue
            fi
        fi
        # Clean detached HEAD, or not yet populated (fresh clone) -> safe to update.
        safe+=("$p")
    done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' 2>/dev/null | awk '{print $2}')

    local item
    for item in "${prot[@]}"; do
        echo -e "${YELLOW}[INFO]${NC} sync: leaving submodule with local work untouched: ${item}" >&2
    done

    [ "${#safe[@]}" -eq 0 ] && return 0
    git submodule update --init --recursive --quiet -- "${safe[@]}" 2>&1
    return $?
}

# Machine + timestamp
if [ -n "$COMPUTERNAME" ]; then
    MACHINE="$COMPUTERNAME"
else
    MACHINE=$(hostname)
fi
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")

# --- Coord visibility signal (BEST-EFFORT, never blocks/fails the sync) -------
# Publishes a per-machine coord component so the fleet can see this machine's
# sync state. Pure visibility: every call is guarded so it can NEVER trip the
# script's `set -e`, slow the sync beyond a tiny timeout, or change the exit code.
COORD_BASE="http://172.16.3.30:8001/api/coord"
COORD_SYNC_STARTED=0
coord_signal() {
    local state="${1:-}"
    curl -s --connect-timeout 2 -m 3 -o /dev/null -X PUT \
        "$COORD_BASE/components/claudetools/git_sync_${MACHINE}" \
        -H "Content-Type: application/json" \
        -d "{\"state\":\"${state}\",\"version\":\"1.0.0\",\"notes\":\"${state} at ${TIMESTAMP} (${USER_DISPLAY:-?})\",\"updated_by\":\"${MACHINE}/sync\"}" \
        2>/dev/null || true
    return 0
}

echo -e "${GREEN}[OK]${NC} Starting ClaudeTools sync from $MACHINE at $TIMESTAMP"

# Navigate to ClaudeTools directory
# Read from identity.json (machine-specific, set during onboarding)
IDENTITY_PATH=""
for candidate in "$HOME/.claude/identity.json" ".claude/identity.json"; do
    if [ -f "$candidate" ]; then
        IDENTITY_PATH="$candidate"
        break
    fi
done

if [ -n "$IDENTITY_PATH" ] && command -v jq >/dev/null 2>&1; then
    REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH" 2>/dev/null)
fi

# Fallback: git detection if identity.json doesn't have claudetools_root yet
if [ -z "$REPO_ROOT" ]; then
    REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
fi

if [ -z "$REPO_ROOT" ] || [ ! -d "$REPO_ROOT/.git" ]; then
    echo -e "${RED}[ERROR]${NC} Cannot locate ClaudeTools repo. Add 'claudetools_root' to identity.json"
    exit 1
fi

cd "$REPO_ROOT"

echo -e "${GREEN}[OK]${NC} Working directory: $(pwd)"

# --- Concurrency lock --------------------------------------------------------
# WHY: multiple sync runs on ONE machine must NOT overlap. An interactive /sync
# or /save can collide with the scheduled-task sync, or two concurrent Claude
# sessions can each stage + commit + fetch + rebase + push and interleave their
# git state — corrupting an in-progress rebase, orphaning commits, or pushing a
# half-built tree. We serialize the whole claudetools critical section (Phase 1a
# submodule update, staging, commit, fetch, rebase, push — and by extension the
# vault phase) behind a single per-machine lock.
#
# The lock primitive (mkdir-atomic lock, stale detection, ownership-checked
# release, exit-75-on-contention) lives in the SHAREABLE library sync-lock.sh so
# other commit paths (/scc, /checkpoint) can contend on the SAME lock dir. We
# set SYNC_LOCK_DIR explicitly, source the library (which defines the vars +
# functions but installs NO trap and acquires NOTHING on source), then install
# our own EXIT trap and acquire — exactly as before. We are already cd'd into
# REPO_ROOT, and the path is absolute, so the source resolves from any CWD.
SYNC_LOCK_DIR="$REPO_ROOT/.git/claudetools-sync.lock"
# shellcheck source=./sync-lock.sh
source "$REPO_ROOT/.claude/scripts/sync-lock.sh"

# Finalize: best-effort coord signal (only if we actually started a sync), then
# ALWAYS release the lock (idempotent + ownership-gated). $? is captured FIRST so
# the coord branch reflects the real script outcome. This trap must NOT call
# `exit` — letting it return preserves the script's true exit code.
sync_finalize() {
    local rc=$?
    if [ "$COORD_SYNC_STARTED" = "1" ]; then
        if [ "$rc" = "0" ]; then coord_signal idle; else coord_signal degraded; fi
    fi
    release_sync_lock
    return "$rc"   # preserve the script's true exit code regardless of release_sync_lock's status
}
trap sync_finalize EXIT INT TERM
acquire_sync_lock
echo -e "${GREEN}[OK]${NC} Acquired sync lock ($SYNC_LOCK_DIR)"
COORD_SYNC_STARTED=1   # set BEFORE the signal so a crash in the gap still finalizes (degraded)
coord_signal syncing
# --- end concurrency lock ----------------------------------------------------

# Detect Python interpreter — read from identity.json first, fall back to detection
PYTHON=""
if [ -f ".claude/identity.json" ] && command -v jq >/dev/null 2>&1; then
    PYTHON=$(jq -r '.python.command // empty' ".claude/identity.json" 2>/dev/null)
fi

# Fallback: auto-detect if not in identity.json
if [ -z "$PYTHON" ]; then
    for candidate in py python3 python; do
        if command -v "$candidate" >/dev/null 2>&1; then
            if "$candidate" -c "import sys; sys.exit(0)" >/dev/null 2>&1; then
                PYTHON="$candidate"
                break
            fi
        fi
    done
fi

if [ -z "$PYTHON" ]; then
    echo -e "${RED}[ERROR]${NC} No Python interpreter found. Run .claude/scripts/migrate-identity.sh to populate identity.json."
    exit 1
fi

# Load user identity
USER_DISPLAY="unknown"
USER_GITEA=""
USER_EMAIL=""
if [ -f ".claude/identity.json" ]; then
    USER_DISPLAY=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('full_name', d.get('user','unknown')))" 2>/dev/null || echo "unknown")
    USER_GITEA=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "")
    USER_EMAIL=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('email',''))" 2>/dev/null || echo "")
fi
echo -e "${GREEN}[OK]${NC} Syncing as: $USER_DISPLAY (machine: $MACHINE)"

# Force this repo's commit authorship to match identity.json before any commit.
# Skip if identity.json was unreadable (USER_DISPLAY left at the "unknown"
# sentinel) — overwriting correct config with "unknown" would be worse than
# leaving it alone, and is the exact mis-attribution this guard prevents.
if [ "$USER_DISPLAY" != "unknown" ]; then
    reconcile_git_identity "$USER_DISPLAY" "$USER_EMAIL"
else
    echo -e "${YELLOW}[WARNING]${NC} identity.json present but unreadable — leaving existing git config untouched (not reconciling)."
fi

# Warn (don't block) if identity.json's machine field disagrees with the real
# hostname — the classic "stale identity.json copied to a new box" failure that
# stamps logs/commits with the wrong machine. Compare case-insensitively and
# ignore a trailing ".local" (macOS).
if [ -f ".claude/identity.json" ]; then
    ID_MACHINE=$($PYTHON -c "import json; print(json.load(open('.claude/identity.json')).get('machine',''))" 2>/dev/null || echo "")
    norm() { printf '%s' "$1" | tr 'A-Z' 'a-z' | sed 's/\.local$//'; }
    if [ -n "$ID_MACHINE" ] && [ "$(norm "$ID_MACHINE")" != "$(norm "$MACHINE")" ]; then
        echo -e "${YELLOW}[WARNING]${NC} identity.json machine '${ID_MACHINE}' != hostname '${MACHINE}' — identity.json may be stale on this box. Fix it before attributing work."
    fi
fi

# Phase 1a: Update submodules to latest remote
# `git submodule foreach` only visits *initialized* submodules, so a fresh clone
# would silently skip population and the old "[OK] updated" message lied. We now
# explicitly init+populate each submodule declared in .gitmodules, then advance
# it to its remote branch tip. Iterating .gitmodules (not the index) sidesteps
# any orphaned gitlink that lacks a .gitmodules mapping. Credentials are inherited
# from the parent origin URL so non-interactive init works without a credential
# helper; .gitmodules itself stays credential-free.
echo ""
echo "=== Phase 1a: Submodule update ==="

if [ -f ".gitmodules" ] && git config --file .gitmodules --get-regexp '^submodule\..*\.path$' >/dev/null 2>&1; then
    # Temporarily disable errexit — submodule ops emit non-fatal warnings that
    # would otherwise kill the script under set -e.
    set +e

    # If origin embeds credentials (https://user:pass@host/...), reuse its
    # scheme+userinfo+host for submodule clones.
    PARENT_URL="$(git config --get remote.origin.url)"
    CRED_HOST=""
    case "$PARENT_URL" in
        https://*@*) CRED_HOST="$(printf '%s' "$PARENT_URL" | sed -E 's#^(https://[^/]+)/.*#\1#')" ;;
    esac

    SUB_COUNT=0
    while read -r pkey ppath; do
        [ -z "$ppath" ] && continue
        name="$(printf '%s' "$pkey" | sed -E 's#^submodule\.(.*)\.path$#\1#')"
        SUB_COUNT=$((SUB_COUNT + 1))
        # Register in .git/config (no-op if already initialized).
        git submodule init -- "$ppath" >/dev/null 2>&1
        # Override the local clone URL with credentials when available.
        if [ -n "$CRED_HOST" ]; then
            gm_url="$(git config --file .gitmodules --get "submodule.${name}.url")"
            case "$gm_url" in
                https://*)
                    sub_path="$(printf '%s' "$gm_url" | sed -E 's#^https://[^/]+(/.*)#\1#')"
                    git config "submodule.${name}.url" "${CRED_HOST}${sub_path}"
                    ;;
            esac
        fi
        # Populate ONLY if missing (fresh clone). Do NOT re-run on an
        # already-populated submodule: `git submodule update` checks out the
        # parent's pinned gitlink (which CLAUDE.md intentionally lets lag main)
        # in DETACHED HEAD, clobbering whatever branch / in-progress work the
        # developer has checked out in the submodule and reverting it to the
        # stale pin. (Friction 2026-06-21: this silently reset guru-rmm to a
        # 5-commits-stale commit on every sync — detaching HEAD, discarding
        # uncommitted edits, and making the working tree lag main.)
        if ! git -C "$ppath" rev-parse --git-dir >/dev/null 2>&1; then
            git submodule update --init -- "$ppath" >/dev/null 2>&1
        fi
        # Reconcile this submodule's git identity to match identity.json — same
        # guarantee the parent repo gets at sync.sh:157-158. Without this, commits
        # in newly-cloned submodules land under the system default (e.g.
        # "<unix-user> @<hostname>") instead of the actual human. Skip if
        # identity.json was unreadable (USER_DISPLAY left at "unknown").
        # Drift incident that motivated this: youtube-sync-docker commits
        # ef903c8 + fdff0a7 from GURU-KALI 2026-05-31.
        if [ "$USER_DISPLAY" != "unknown" ]; then
            (cd "$ppath" && reconcile_git_identity "$USER_DISPLAY" "$USER_EMAIL") || true
        fi
    done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$')

    # Advance each initialized submodule to its remote branch tip. Opt-in only
    # (see arg-parsing block near the top): a full network fetch + ff-merge of
    # every submodule on every sync is normally wasted work and churns the
    # pinned-commit pointer, which CLAUDE.md says SHOULD lag `main`.
    if [ "$ADVANCE_SUBMODULES" = "1" ]; then
        git submodule foreach --quiet '
            git fetch origin --quiet 2>/dev/null
            git checkout main --quiet 2>/dev/null || git checkout master --quiet 2>/dev/null
            git merge --ff-only origin/main --quiet 2>/dev/null || \
                git merge --ff-only origin/master --quiet 2>/dev/null
        ' 2>/dev/null
        set -e
        echo -e "${GREEN}[OK]${NC} Submodules init+advanced (${SUB_COUNT} configured)."
    else
        set -e
        echo -e "${YELLOW}[INFO]${NC} Submodule advance skipped (pinned commit lagging main is expected; pass --with-submodules to fetch+advance). ${SUB_COUNT} configured."
    fi
else
    echo -e "${GREEN}[OK]${NC} No submodules."
fi

# Phase 1: Local changes
echo ""
echo "=== Phase 1: Local changes ==="

# Detect any change including untracked-only (git diff-index ignores untracked files,
# so a brand-new file with no tracked changes would otherwise be silently skipped).
if [ -n "$(git status --porcelain)" ]; then
    echo -e "${YELLOW}[INFO]${NC} Local changes detected:"
    git status --short
    echo ""

    echo -e "${GREEN}[OK]${NC} Staging all changes..."
    purge_garbled_paths
    git add -A

    # Submodule-safe staging (Task 1): `git add -A` stages submodule gitlink (pointer)
    # changes. The parent's pinned commit intentionally lags the submodule's main, so
    # auto-committing the pointer bumps a possibly-stale gitlink. Unstage every submodule
    # gitlink unless the operator opted in with --with-submodules. This eliminates the
    # manual "detach submodule to its pin before /save" dance.
    if [ "${ADVANCE_SUBMODULES:-0}" != "1" ] && [ -f ".gitmodules" ]; then
        while IFS= read -r sm_path; do
            [ -n "$sm_path" ] || continue
            git reset -q HEAD -- "$sm_path" 2>/dev/null || true
        done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' | awk '{print $2}')
    fi

    # Commit message (Co-Authored-By uses local git user if configured)
    COMMIT_MSG="sync: auto-sync from $MACHINE at $TIMESTAMP

Author: $USER_DISPLAY
Machine: $MACHINE
Timestamp: $TIMESTAMP"

    if git diff-index --quiet --cached HEAD -- 2>/dev/null; then
        echo -e "${GREEN}[OK]${NC} No stageable changes (submodule pointer + internal changes skipped)."
    else
        # Harness guard (Task 4): WARN-ONLY during rollout — logs footguns (conflict
        # markers, unencrypted sops, private-key material) to .claude/harness/guard.log
        # but does NOT block unless HARNESS_GUARD_FATAL=1. SKIP_HARNESS_GUARD=1 bypasses.
        GUARD_RC=0
        bash .claude/scripts/harness-guard.sh || GUARD_RC=$?
        if [ "$GUARD_RC" != "0" ]; then
            echo -e "${YELLOW}[WARNING]${NC} harness-guard blocked the commit (HARNESS_GUARD_FATAL set). Staged changes left in place; set SKIP_HARNESS_GUARD=1 to override."
        else
            git commit -m "$COMMIT_MSG"
            echo -e "${GREEN}[OK]${NC} Committed."
        fi
    fi
else
    echo -e "${GREEN}[OK]${NC} No local changes to commit."
fi

# Phase 2: Remote sync
echo ""
echo "=== Phase 2: Fetch + inspect ==="

LOCAL_BEFORE=$(git rev-parse HEAD)

echo -e "${GREEN}[OK]${NC} Fetching from origin..."
# --no-recurse-submodules: parent fetch must NOT implicitly recurse. A transient
# dead gitlink in incoming parent history (force-pushed-out submodule commit)
# would otherwise abort the whole sync under `set -e`. Phase 1a already advanced
# submodules to their remote tips; a post-rebase `git submodule update` reconciles
# them to the new parent pointers.
git fetch origin --quiet --no-recurse-submodules

LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main 2>/dev/null || git rev-parse origin/master 2>/dev/null || echo "$LOCAL")
REMOTE_BRANCH="origin/main"
if ! git rev-parse origin/main >/dev/null 2>&1; then
    REMOTE_BRANCH="origin/master"
fi

# Count and show incoming
INCOMING_COUNT=$(git rev-list --count HEAD..$REMOTE_BRANCH 2>/dev/null || echo 0)
OUTGOING_COUNT=$(git rev-list --count $REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)

if [ "$INCOMING_COUNT" -gt 0 ]; then
    echo ""
    echo -e "${CYAN}--- Incoming: $INCOMING_COUNT commits from remote ---${NC}"
    git log --oneline --format='  %C(yellow)%h%Creset  %C(cyan)%an%Creset  %s  %C(dim)(%ar)%Creset' HEAD..$REMOTE_BRANCH | head -30
    echo ""
    echo -e "${CYAN}--- Files touched by incoming commits ---${NC}"
    git diff --stat HEAD..$REMOTE_BRANCH | tail -20

    # Wiki summary section
    WIKI_CHANGES=$(git diff --name-status HEAD..$REMOTE_BRANCH -- 'wiki/*.md' 'wiki/*/*.md' 2>/dev/null || true)
    if [ -n "$WIKI_CHANGES" ]; then
        echo ""
        echo -e "${CYAN}--- Wiki knowledge layer updates ---${NC}"

        # Categorize by type
        WIKI_CLIENTS=$(echo "$WIKI_CHANGES" | grep 'wiki/clients/' | awk '{printf "  %s %s\n", $1, $2}' | sed 's|wiki/clients/||' | sed 's|\.md$||')
        WIKI_PROJECTS=$(echo "$WIKI_CHANGES" | grep 'wiki/projects/' | awk '{printf "  %s %s\n", $1, $2}' | sed 's|wiki/projects/||' | sed 's|\.md$||')
        WIKI_SYSTEMS=$(echo "$WIKI_CHANGES" | grep 'wiki/systems/' | awk '{printf "  %s %s\n", $1, $2}' | sed 's|wiki/systems/||' | sed 's|\.md$||')
        WIKI_PATTERNS=$(echo "$WIKI_CHANGES" | grep 'wiki/patterns/' | awk '{printf "  %s %s\n", $1, $2}' | sed 's|wiki/patterns/||' | sed 's|\.md$||')
        WIKI_META=$(echo "$WIKI_CHANGES" | grep -E 'wiki/(index|overview)\.md' | awk '{printf "  %s %s\n", $1, $2}' | sed 's|wiki/||' | sed 's|\.md$||')

        [ -n "$WIKI_CLIENTS" ] && echo -e "${CYAN}Clients:${NC}\n$WIKI_CLIENTS"
        [ -n "$WIKI_PROJECTS" ] && echo -e "${CYAN}Projects:${NC}\n$WIKI_PROJECTS"
        [ -n "$WIKI_SYSTEMS" ] && echo -e "${CYAN}Systems:${NC}\n$WIKI_SYSTEMS"
        [ -n "$WIKI_PATTERNS" ] && echo -e "${CYAN}Patterns:${NC}\n$WIKI_PATTERNS"
        [ -n "$WIKI_META" ] && echo -e "${CYAN}Meta:${NC}\n$WIKI_META"

        # Count by status
        WIKI_ADDED=$(echo "$WIKI_CHANGES" | grep -c '^A' || echo 0)
        WIKI_MODIFIED=$(echo "$WIKI_CHANGES" | grep -c '^M' || echo 0)
        WIKI_DELETED=$(echo "$WIKI_CHANGES" | grep -c '^D' || echo 0)

        echo -e "${CYAN}Summary:${NC} $WIKI_ADDED added, $WIKI_MODIFIED modified, $WIKI_DELETED deleted"
    fi
else
    echo -e "${GREEN}[OK]${NC} No incoming changes."
fi

if [ "$OUTGOING_COUNT" -gt 0 ]; then
    echo ""
    echo -e "${CYAN}--- Outgoing: $OUTGOING_COUNT commits to remote ---${NC}"
    git log --oneline --format='  %C(yellow)%h%Creset  %C(cyan)%an%Creset  %s  %C(dim)(%ar)%Creset' $REMOTE_BRANCH..HEAD | head -30
fi

# Phase 3: Pull (if needed)
if [ "$INCOMING_COUNT" -gt 0 ]; then
    echo ""
    echo "=== Phase 3: Pull (rebase) ==="
    # --no-recurse-submodules: same reason as the fetch above — don't let a
    # dead submodule ref in incoming history kill the parent rebase.
    if git pull origin main --rebase --no-recurse-submodules; then
        echo -e "${GREEN}[OK]${NC} Pulled successfully."
    else
        echo -e "${RED}[ERROR]${NC} Pull failed (likely conflicts). Resolve and re-run sync."
        exit 1
    fi

    # Submodules may have advanced via the parent's gitlinks. Update them to match,
    # but tolerate per-submodule failures (e.g., transient dead refs in history) —
    # the working tree is still useful even if one submodule can't catch up.
    # First attempt's output is captured (not streamed): a recurring, auto-healable
    # cause is an untracked file colliding with the incoming gitlink commit, and git's
    # raw "fatal: Unable to checkout" reads alarmingly right before a clean resolve.
    # Only surface the raw output if the resolve+retry below also fails.
    set +e
    SUB_OUT=$(submodule_update_safe)
    SUB_RC=$?
    set -e
    if [ "$SUB_RC" -ne 0 ]; then
        # Move any untracked files colliding with the incoming commit aside, retry once.
        if resolve_submodule_collisions; then
            set +e
            SUB_OUT=$(submodule_update_safe)
            SUB_RC=$?
            set -e
        fi
        if [ "$SUB_RC" -ne 0 ]; then
            [ -n "$SUB_OUT" ] && printf '%s\n' "$SUB_OUT" | grep -v '^$'
            echo -e "${YELLOW}[WARNING]${NC} One or more submodules failed to update — likely a transient dead ref. Parent repo is current; investigate the submodule manually if a pointer is wrong."
        else
            echo -e "${GREEN}[OK]${NC} Submodules reconciled (set aside colliding untracked file(s))."
        fi
    fi
fi

# Phase 4: Push (if needed)
OUTGOING_AFTER_PULL=$(git rev-list --count $REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)
if [ "$OUTGOING_AFTER_PULL" -gt 0 ]; then
    echo ""
    echo "=== Phase 4: Push ==="
    if git push origin main; then
        echo -e "${GREEN}[OK]${NC} Pushed successfully."
    else
        echo -e "${RED}[ERROR]${NC} Push failed. Check auth / network."
        exit 1
    fi
else
    echo -e "${GREEN}[OK]${NC} Nothing to push."
fi

# Phase 5: Scan pulled session logs for cross-user messages
# Look for "## Note for" or "## Message for" sections in any session log
# touched by incoming commits. Print them prominently so they aren't missed.
if [ "$INCOMING_COUNT" -gt 0 ] && [ -n "$LOCAL_BEFORE" ]; then
    CHANGED_LOGS=$(git diff --name-only "$LOCAL_BEFORE"..HEAD -- '**/session-logs/*.md' 'session-logs/*.md' 2>/dev/null || true)
    if [ -n "$CHANGED_LOGS" ]; then
        NOTES_FOUND=0
        for LOG_FILE in $CHANGED_LOGS; do
            if [ -f "$LOG_FILE" ]; then
                # Extract author from "## User" block and any "## Note for" / "## Message for" sections
                NOTE_CONTENT=$(awk '
                    /^## (Note|Message) for /{ in_note=1; header=$0; next }
                    in_note && /^## /{ in_note=0 }
                    in_note{ buf=buf"\n"$0 }
                    END{ if(buf) print header buf }
                ' "$LOG_FILE")
                if [ -n "$NOTE_CONTENT" ]; then
                    if [ "$NOTES_FOUND" -eq 0 ]; then
                        echo ""
                        echo -e "${YELLOW}============================================================${NC}"
                        echo -e "${YELLOW}  MESSAGES FROM OTHER TEAM MEMBERS${NC}"
                        echo -e "${YELLOW}============================================================${NC}"
                        NOTES_FOUND=1
                    fi
                    LOG_AUTHOR=$(awk '/^- \*\*User:\*\*/{print; exit}' "$LOG_FILE" | sed 's/.*\*\*User:\*\* //')
                    LOG_DATE=$(basename "$LOG_FILE" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)
                    echo ""
                    echo -e "${YELLOW}  From: ${LOG_AUTHOR:-unknown}  |  ${LOG_DATE:-unknown date}  |  ${LOG_FILE}${NC}"
                    echo -e "${YELLOW}------------------------------------------------------------${NC}"
                    echo "$NOTE_CONTENT"
                fi
            fi
        done
        if [ "$NOTES_FOUND" -gt 0 ]; then
            echo ""
            echo -e "${YELLOW}============================================================${NC}"
            echo -e "${YELLOW}  Address the above before continuing with other work.${NC}"
            echo -e "${YELLOW}============================================================${NC}"
            echo ""
        fi
    fi
fi

# Phase 5b: Apply config — sync slash commands to the global Claude dir.
# The repo's .claude/commands/ is canonical; this keeps ~/.claude/commands current
# so the CLI loads the latest skills without a manual copy. One-way (repo -> global),
# idempotent (only copies new/changed files), and soft-fails so it never aborts a sync.
echo ""
echo "=== Phase 5b: Apply config (commands -> global) ==="
GLOBAL_CMD_DIR="$HOME/.claude/commands"
set +e
mkdir -p "$GLOBAL_CMD_DIR"
CMD_UPDATED=0
CMD_NAMES=""
for src in .claude/commands/*.md; do
    [ -f "$src" ] || continue
    dst="$GLOBAL_CMD_DIR/$(basename "$src")"
    if [ ! -f "$dst" ] || ! cmp -s "$src" "$dst"; then
        if cp "$src" "$dst"; then
            CMD_UPDATED=$((CMD_UPDATED + 1))
            CMD_NAMES="$CMD_NAMES $(basename "$src")"
        fi
    fi
done
set -e
if [ "$CMD_UPDATED" -gt 0 ]; then
    echo -e "${GREEN}[OK]${NC} Commands synced to global: $CMD_UPDATED updated —$CMD_NAMES"
else
    echo -e "${GREEN}[OK]${NC} Global commands already current."
fi

# Phase 5c: Apply config — sync skills to the global Claude dir.
# Skills are directories (SKILL.md + scripts/refs); the global ~/.claude/skills/ is
# where the CLI loads invocable skills from. A machine that lost its global skills
# (e.g. wiped) self-heals here. One-way (repo -> global), idempotent, soft-fails.
echo ""
echo "=== Phase 5c: Apply config (skills -> global) ==="
GLOBAL_SKILL_DIR="$HOME/.claude/skills"
set +e
mkdir -p "$GLOBAL_SKILL_DIR"
SKILL_UPDATED=0
SKILL_NAMES=""
if [ -d ".claude/skills" ]; then
    for d in .claude/skills/*/; do
        [ -d "$d" ] || continue
        name=$(basename "$d")
        dst="$GLOBAL_SKILL_DIR/$name"
        if [ ! -d "$dst" ] || ! diff -rq ".claude/skills/$name" "$dst" >/dev/null 2>&1; then
            rm -rf "$dst"
            if cp -rf ".claude/skills/$name" "$GLOBAL_SKILL_DIR/"; then
                SKILL_UPDATED=$((SKILL_UPDATED + 1))
                SKILL_NAMES="$SKILL_NAMES $name"
            fi
        fi
    done
fi
set -e
if [ "$SKILL_UPDATED" -gt 0 ]; then
    echo -e "${GREEN}[OK]${NC} Skills synced to global: $SKILL_UPDATED updated —$SKILL_NAMES"
else
    echo -e "${GREEN}[OK]${NC} Global skills already current."
fi

# Phase 6: Vault sync
echo ""
echo "=== Phase 6: Vault sync ==="

VAULT_PATH=""
if [ -f ".claude/identity.json" ]; then
    VAULT_PATH=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('vault_path',''))" 2>/dev/null || echo "")
fi

if [ -z "$VAULT_PATH" ]; then
    echo -e "${YELLOW}[INFO]${NC} vault_path not set in identity.json — skipping vault sync."
elif [ ! -d "$VAULT_PATH/.git" ]; then
    echo -e "${YELLOW}[WARNING]${NC} Vault path '$VAULT_PATH' is not a git repo — skipping."
else
    CLAUDETOOLS_DIR=$(pwd)
    cd "$VAULT_PATH"
    echo -e "${GREEN}[OK]${NC} Vault: $VAULT_PATH"

    # Same attribution guard for the vault repo (it has its own git config).
    # Same "unknown" skip as the main repo — the main-repo warning already fired.
    if [ "$USER_DISPLAY" != "unknown" ]; then
        reconcile_git_identity "$USER_DISPLAY" "$USER_EMAIL"
    fi

    # Commit any local vault changes (porcelain catches untracked-only too)
    if [ -n "$(git status --porcelain)" ]; then
        echo -e "${YELLOW}[INFO]${NC} Local vault changes detected — committing..."
        purge_garbled_paths
        git add -A
        git commit -m "sync: auto-sync vault from $MACHINE at $TIMESTAMP"
        echo -e "${GREEN}[OK]${NC} Vault committed."
    else
        echo -e "${GREEN}[OK]${NC} No local vault changes."
    fi

    VAULT_LOCAL_BEFORE=$(git rev-parse HEAD)
    # --no-recurse-submodules: vault doesn't use submodules today, but stay
    # consistent with the main-repo fetch so this script remains robust if that
    # ever changes.
    git fetch origin --quiet --no-recurse-submodules

    VAULT_REMOTE_BRANCH="origin/main"
    if ! git rev-parse origin/main >/dev/null 2>&1; then
        VAULT_REMOTE_BRANCH="origin/master"
    fi

    VAULT_INCOMING=$(git rev-list --count HEAD..$VAULT_REMOTE_BRANCH 2>/dev/null || echo 0)
    VAULT_OUTGOING=$(git rev-list --count $VAULT_REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)

    if [ "$VAULT_INCOMING" -gt 0 ]; then
        echo -e "${CYAN}--- Vault: $VAULT_INCOMING incoming commit(s) ---${NC}"
        git log --oneline --format='  %C(yellow)%h%Creset  %C(cyan)%an%Creset  %s  %C(dim)(%ar)%Creset' HEAD..$VAULT_REMOTE_BRANCH | head -10
        if git pull origin main --rebase --no-recurse-submodules; then
            echo -e "${GREEN}[OK]${NC} Vault pulled."
        else
            echo -e "${RED}[ERROR]${NC} Vault pull failed — resolve conflicts manually in $VAULT_PATH."
        fi
    else
        echo -e "${GREEN}[OK]${NC} Vault: no incoming changes."
    fi

    VAULT_OUTGOING_AFTER=$(git rev-list --count $VAULT_REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)
    if [ "$VAULT_OUTGOING_AFTER" -gt 0 ]; then
        if git push origin main; then
            echo -e "${GREEN}[OK]${NC} Vault pushed ($VAULT_OUTGOING_AFTER commit(s))."
        else
            echo -e "${RED}[ERROR]${NC} Vault push failed."
        fi
    else
        echo -e "${GREEN}[OK]${NC} Vault: nothing to push."
    fi

    cd "$CLAUDETOOLS_DIR"
fi

# Phase 7: Pending To-Do Review
echo ""
echo "=== Phase 7: Pending To-Dos ==="

# Coord API endpoint — per-machine override in identity.json so off-network/VPN/
# Tailscale boxes can point elsewhere. Default to the on-LAN address for backward
# compat with existing machines that haven't been re-migrated yet.
COORD_API=""
if [ -f ".claude/identity.json" ]; then
    COORD_API=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('coord_api',''))" 2>/dev/null || echo "")
fi
[ -z "$COORD_API" ] && COORD_API="http://172.16.3.30:8001"  # default when identity.json doesn't define coord_api
TODO_USER=""
TODO_MACHINE="$MACHINE"
if [ -f ".claude/identity.json" ]; then
    TODO_USER=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "")
fi

if [ -n "$TODO_USER" ]; then
    TODO_JSON=$(curl -s --max-time 5 \
        "${COORD_API}/api/coord/todos?for_user=${TODO_USER}&for_machine=${TODO_MACHINE}&status_filter=pending&limit=200" \
        2>/dev/null || echo "[]")

    TODO_COUNT=$($PYTHON -c "
import json, sys
try:
    data = json.loads('''$TODO_JSON''')
    print(len(data))
except:
    print(0)
" 2>/dev/null || echo 0)

    if [ "$TODO_COUNT" -gt 0 ]; then
        echo -e "${YELLOW}  $TODO_COUNT pending item(s) for ${TODO_USER}/${TODO_MACHINE}:${NC}"
        $PYTHON - <<PYEOF
import json, sys

raw = """$TODO_JSON"""
try:
    todos = json.loads(raw)
except Exception as e:
    print(f"  [WARNING] Could not parse todos: {e}")
    sys.exit(0)

# Group by project_key; None -> "Personal"
groups = {}
for t in todos:
    if t.get("parent_id"):
        continue  # sub-tasks shown under parent
    key = t.get("project_key") or "Personal"
    groups.setdefault(key, []).append(t)

# Build sub-task lookup
subtask_map = {}
for t in todos:
    pid = t.get("parent_id")
    if pid:
        subtask_map.setdefault(pid, []).append(t)

CYAN  = "\033[0;36m"
RESET = "\033[0m"
YELLOW= "\033[1;33m"

for group_name in sorted(groups.keys(), key=lambda x: (x == "Personal", x)):
    print(f"\n  {CYAN}[{group_name}]{RESET}")
    for t in groups[group_name]:
        assigned = ""
        if t.get("assigned_to_user") and t["assigned_to_user"] != "$TODO_USER":
            assigned = f"  -> {t['assigned_to_user']}"
        if t.get("assigned_to_machine") and t["assigned_to_machine"].upper() != "$TODO_MACHINE".upper():
            assigned += f"/{t['assigned_to_machine']}"
        auto = " [auto]" if t.get("auto_created") else ""
        due = f"  due:{t['due_at'][:16]}" if t.get("due_at") else ""
        print(f"    [ ] {t['text']}{auto}{due}{assigned}  (id:{t['id'][:8]})")
        for st in subtask_map.get(t["id"], []):
            st_due = f"  due:{st['due_at'][:16]}" if st.get("due_at") else ""
            print(f"        [ ] {st['text']}{st_due}  (id:{st['id'][:8]})")
PYEOF
        echo ""
    else
        echo -e "${GREEN}[OK]${NC} No pending to-dos."
    fi
else
    echo -e "${YELLOW}[INFO]${NC} No identity — skipping todo check."
fi

# Phase 8: Summary
echo ""
echo "=== Sync Summary ==="

if [ "$INCOMING_COUNT" -gt 0 ]; then
    INCOMING_AUTHORS=$(git log --format='%an' $LOCAL_BEFORE..HEAD 2>/dev/null | sort | uniq -c | sort -rn | awk '{printf "%s (%s), ", substr($0, index($0,$2)), $1}' | sed 's/, $//')
    echo -e "${CYAN}Pulled in:${NC} $INCOMING_COUNT commit(s) — authors: ${INCOMING_AUTHORS:-unknown}"
fi
if [ "$OUTGOING_AFTER_PULL" -gt 0 ]; then
    echo -e "${CYAN}Pushed out:${NC} $OUTGOING_AFTER_PULL commit(s) by $USER_DISPLAY"
fi
if [ "$INCOMING_COUNT" -eq 0 ] && [ "$OUTGOING_AFTER_PULL" -eq 0 ]; then
    echo -e "${GREEN}Already in sync — no commits moved in either direction.${NC}"
fi

echo -e "${GREEN}[OK]${NC} HEAD: $(git log -1 --oneline)"
echo -e "${GREEN}[OK]${NC} Status: $(git status -sb | head -1)"

echo ""
echo -e "${GREEN}[SUCCESS]${NC} Sync complete."
