#!/usr/bin/env bash
# install-release-core — the boot resolver for the pull-model distribution.
#
# Resolves the release_core wheel attached to a GitHub release and installs it.
# This is THE single definition of "how a repo gets the current release_core"
# (ADR-0003). Both boot
# contexts call it: the SessionStart hook (setup-dev-env.sh) and the reusable CI
# workflows. It runs BEFORE release_core is installed, so it is pure shell — it
# cannot itself be a release_core console-script.
#
# Install model = "latest, force-reinstall":
#   - Default source is the repo's `releases/latest`.
#   - `--major vN` instead picks the LATEST release in that major line that
#     carries the wheel. This is the load-bearing v3-safety filter: the wheel
#     version is not stamped from the tag (it is a static 0.0.1), so once a v3
#     release exists, `releases/latest` would hand a v2-pinned consumer the v3
#     wheel. Pinning to the consumer's major line keeps `@vN` honest.
#   - Install is `pip install --force-reinstall "$url"`, NOT `-U`: because the
#     wheel version is static, `pip install -U` sees the same 0.0.1 already
#     satisfied and SKIPS it, defeating the pull model. --force-reinstall always
#     reinstalls. There is deliberately NO --no-deps: release_core declares real
#     third-party dependencies (click) that pip must resolve from PyPI on each
#     boot. They are pure-Python and PyPI is reachable wherever this release URL
#     is, so the boot stays a no-compile wheel pull.
#
# Exactly ONE wheel asset must match per release (ADR: "select one URL and error
# otherwise"). Zero or many is a release-side packaging bug, surfaced loudly here
# rather than silently installing the wrong artifact.
#
# After installing, this runs `release-core init` in the current repo by default
# (--no-init to skip) — the "one command does the boot" ergonomic. A bare `init`
# now materializes the WHOLE managed tree from the wheel bundle (the .release/
# build dir + every working-tree mirror — skills, ORIENTATION, configs, the
# CLAUDE.md block) and auto-commits managed changes (#476 cutover) — NOT just the
# config subset. This is the pull-model self-sync: the wheel pull carries the whole
# tree, so no push is needed (there is no push mechanism). It is NOT a pip
# post-install hook (wheels have none, and init is repo-specific while pip is
# environment-level): the resolver runs init explicitly, at the repo root, on the
# JUST-installed console-script (resolved across venv / --user / system layouts).
# init is best-effort — its failure does NOT fail the resolver (the committed tree
# degrades gracefully; the install, the resolver's core job, still succeeded).
#
# Source of truth: arthur-debert/release bin/install-release-core. Sister to
# install-release-token / install-release-secrets.

set -euo pipefail

REPO="arthur-debert/release"
MAJOR=""
PRINT_URL=0
NO_INIT=0
FROM_SOURCE=""   # install from a LOCAL path (a checkout's release_core source)
                 # instead of a published wheel — same venv/PATH machinery.
PYTHON="${PYTHON:-python3}"   # interpreter used to BUILD the isolated tool venv

# release_core lives in its OWN dedicated venv — never the user's pip, the system
# site-packages, or a project venv (no `pip --user`, no PEP-668 system writes).
# Its console-scripts are exposed on PATH via symlinks into BIN_DIR. This is the
# pipx model, hand-rolled so it needs no pipx (portable to local, cloud, CI).
# `python -m venv` builds an independent env even when $PYTHON is itself a venv,
# so a quirky `python3` (e.g. one that resolves into a pipx venv) no longer breaks
# the install the way `pip --user` did (rejected inside a venv).
VENV_DIR="${RELEASE_CORE_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/release-core}/venv"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"

usage() {
  cat <<'EOF'
Usage: install-release-core [--major vN] [--repo OWNER/REPO] [--print-url]
                            [--from-source PATH] [--no-init]
                            [--user] [--break-system-packages]

Install release_core into its OWN isolated venv and expose its console-scripts on
PATH, then run `release-core init` in the current repo. The package comes from a
published GitHub-release wheel by default, or from a LOCAL source tree with
--from-source. One install definition; the source is the only thing that varies.

Options:
  --major vN        Pin to the latest release in major line vN (e.g. --major v2).
                    Default: the repo's releases/latest. (Published-wheel mode.)
  --repo OWNER/REPO Source repo for the release (default: arthur-debert/release).
  --from-source PATH
                    Install release_core from a LOCAL path instead of a published
                    wheel — for release's own CI, which must test the code in the
                    current checkout. PATH may be a release checkout ROOT (the
                    nested templates/commons/lib/release_core package dir is
                    located automatically) or the package dir itself. Skips
                    release resolution (no gh/network). Mutually exclusive with
                    --major/--repo.
  --print-url       Print the resolved wheel URL (or the --from-source path) and
                    exit (resolve only, no install, no init).
  --no-init         Install only; do NOT run `release-core init` afterwards
                    (pure-transport mode, e.g. just updating the package).
  --user            Accepted but IGNORED (release_core now installs into its own
                    isolated venv, never the user site). Tolerated so the
                    not-yet-migrated SessionStart caller — which still passes
                    --user/--break-system-packages — keeps working through the
                    first cutover pull.
  --break-system-packages
                    Accepted but IGNORED (see --user).
  -h, --help        Show this help.

Env:
  PYTHON            Interpreter used to BUILD the tool venv (default: python3).
  RELEASE_CORE_HOME Override the tool-venv parent dir (default:
                    ${XDG_DATA_HOME:-~/.local/share}/release-core).
  XDG_BIN_HOME      Where console-scripts are symlinked (default: ~/.local/bin).
EOF
}

while [ $# -gt 0 ]; do
  case "$1" in
    --major)
      [ $# -ge 2 ] || { echo "install-release-core: --major needs a value (e.g. v2)" >&2; exit 64; }
      MAJOR="$2"; shift 2 ;;
    --major=*) MAJOR="${1#--major=}"; shift ;;
    --repo)
      [ $# -ge 2 ] || { echo "install-release-core: --repo needs a value" >&2; exit 64; }
      REPO="$2"; shift 2 ;;
    --repo=*) REPO="${1#--repo=}"; shift ;;
    --print-url) PRINT_URL=1; shift ;;
    --from-source)
      [ $# -ge 2 ] || { echo "install-release-core: --from-source needs a PATH" >&2; exit 64; }
      FROM_SOURCE="$2"; shift 2 ;;
    --from-source=*) FROM_SOURCE="${1#--from-source=}"; shift ;;
    --no-init) NO_INIT=1; shift ;;
    # Tolerated no-ops: the deployed (pre-isolation) SessionStart caller still
    # passes these; the isolated-venv install ignores them. Drop after the fleet
    # has migrated to the new setup-dev-env.sh.
    --user) shift ;;
    --break-system-packages) shift ;;
    -h|--help) usage; exit 0 ;;
    *) echo "install-release-core: unknown argument '$1'" >&2; usage >&2; exit 64 ;;
  esac
done

# No explicit --major and not --from-source → derive the major line from THIS
# repo's thin callers (release#551): `uses: <repo>/...@vN` in .github/workflows/
# IS the consumer's pin declaration, so the wheel follows it instead of
# `releases/latest` — which would hand a v2-pinned consumer the v3 wheel the
# moment a v3 release exists. The HIGHEST major wins: consumers legitimately mix
# lines (the copilot-review.yml caller stayed @v1 fleet-wide while the stack
# workflows moved to @v2), and a mid-migration repo should follow the line it is
# moving TO. No callers / no workflows dir → empty → releases/latest, today's
# behavior (e.g. a pure-transport run outside a repo). Same self-deciding-from-
# the-checkout pattern as arm-gate's action_ref pin (release#541).
derive_caller_major() {
  [ -d .github/workflows ] || return 0
  grep -rhoE "${REPO}/[^@\"']*@v[0-9]+" .github/workflows 2>/dev/null \
    | sed 's/.*@v//' \
    | sort -n \
    | tail -n1 \
    | sed 's/^/v/'
}
if [ -z "$MAJOR" ] && [ -z "$FROM_SOURCE" ]; then
  MAJOR="$(derive_caller_major || true)"
  if [ "$MAJOR" = "v" ]; then MAJOR=""; fi   # no match → sed prefixed an empty line
  [ -n "$MAJOR" ] \
    && echo "install-release-core: pinned to major line $MAJOR (derived from this repo's @vN thin callers)" >&2
fi

# The wheel asset name pattern (must match the publish job's output name).
# Two forms: a human-readable one for messages, and a jq-string form whose
# backslash is doubled so it survives embedding in a jq string literal
# (jq parses "\." as an invalid escape; "\\." is a literal-dot regex).
WHEEL_DESC='release_core-*.whl'
WHEEL_JQ_RE='^release_core-.*\\.whl$'

# Emit the single wheel URL for the chosen release, or fail loudly.
#
# Strategy:
#   - no --major: read releases/latest directly (GitHub computes "latest",
#     excluding drafts/prereleases) and pull its wheel asset URLs.
#   - --major vN: list releases (newest-first), drop drafts/prereleases, keep
#     those whose tag is vN or vN.* AND that carry a wheel asset, take the first
#     (newest), emit its wheel URL.
# Either way the result is the array of matching asset URLs; we then require
# exactly one.
resolve_url() {
  urls="$(_resolve_url_list)" || return 1

  n=0
  if [ -n "$urls" ]; then
    n="$(printf '%s\n' "$urls" | grep -c .)"
  fi
  if [ "$n" -eq 0 ]; then
    if [ -n "$MAJOR" ]; then
      echo "install-release-core: no release in major line '$MAJOR' of $REPO carries a wheel asset matching $WHEEL_DESC" >&2
    else
      echo "install-release-core: releases/latest of $REPO has no wheel asset matching $WHEEL_DESC" >&2
    fi
    return 1
  fi
  if [ "$n" -gt 1 ]; then
    echo "install-release-core: expected exactly 1 wheel asset, found $n — release packaging bug:" >&2
    printf '%s\n' "$urls" | sed 's/^/  /' >&2
    return 1
  fi
  printf '%s\n' "$urls"
}

_resolve_url_list() {
  if [ -n "$MAJOR" ]; then
    # Escape dots in the major for the jq regex; double the backslash so it
    # survives the jq string literal. Anchor so `v2` matches v2 / v2.x but not
    # v20.x — `^v2(\.|$)`. per_page=100 (not --paginate) keeps the page
    # deterministic and well past any realistic single-major release count.
    esc="$(printf '%s' "$MAJOR" | sed 's/\./\\\\./g')"
    gh api "repos/${REPO}/releases?per_page=100" --jq "
      [ .[]
        | select(.draft == false and .prerelease == false)
        | select(.tag_name | test(\"^${esc}(\\\\.|\$)\"))
        | select(any(.assets[]; .name | test(\"${WHEEL_JQ_RE}\")))
      ]
      | first
      | .assets[]?
      | select(.name | test(\"${WHEEL_JQ_RE}\"))
      | .browser_download_url
    "
  else
    gh api "repos/${REPO}/releases/latest" --jq "
      .assets[]?
      | select(.name | test(\"${WHEEL_JQ_RE}\"))
      | .browser_download_url
    "
  fi
}

# The package SOURCE: a local tree (--from-source, no release resolution) or the
# resolved published-wheel URL. Everything downstream (venv, install, PATH, init)
# is identical — only the source differs.
if [ -n "$FROM_SOURCE" ]; then
  # The release_core package is NESTED (templates/commons/lib/release_core), and
  # the repo ROOT carries a uv-workspace pyproject.toml that pip cannot build —
  # so check for the nested package FIRST: a checkout root descends to it
  # automatically (release#516); only a path without the nested layout is
  # treated as the package dir itself.
  if [ -f "$FROM_SOURCE/templates/commons/lib/release_core/pyproject.toml" ]; then
    FROM_SOURCE="$FROM_SOURCE/templates/commons/lib/release_core"
  elif [ ! -f "$FROM_SOURCE/pyproject.toml" ]; then
    echo "install-release-core: --from-source '$FROM_SOURCE' has no pyproject.toml (and no nested templates/commons/lib/release_core)" >&2
    exit 1
  fi
  source="$FROM_SOURCE"
else
  source="$(resolve_url)"
fi

if [ "$PRINT_URL" -eq 1 ]; then
  printf '%s\n' "$source"
  exit 0
fi

echo "install-release-core: installing $source into $VENV_DIR" >&2
# Build (once) the DEDICATED venv and install/refresh the wheel INTO IT — fully
# isolated from the user's pip, the system site-packages, and any project venv.
# Reuse an existing venv (fast); recreate only if it's missing or broken (e.g. a
# python upgrade left it dangling).
# Fail fast on a misconfigured VENV_DIR: empty or `/` would make `rm -rf`, the
# venv build, AND the `$VENV_DIR/bin/*` symlink glob below all act on the
# filesystem root (e.g. symlinking /bin/* into ~/.local/bin). Validate before
# touching anything.
if [ -z "$VENV_DIR" ] || [ "$VENV_DIR" = "/" ]; then
  echo "install-release-core: refusing to use invalid VENV_DIR '$VENV_DIR'" >&2
  exit 1
fi
if ! "$VENV_DIR/bin/python" -c 'import sys' >/dev/null 2>&1; then
  rm -rf "$VENV_DIR"
  "$PYTHON" -m venv "$VENV_DIR"
fi
# --force-reinstall: the wheel version is static (0.0.1), so `-U` would see it
# already satisfied and skip — force-reinstall always reinstalls the wheel.
# NO --no-deps: release_core declares real third-party deps (click) which the
# venv's pip resolves from PyPI on each boot (reachable wherever the wheel URL
# was; pure-Python, no compile).
"$VENV_DIR/bin/python" -m pip install --disable-pip-version-check \
  --force-reinstall "$source"

# Stamp the resolved release PROVENANCE into the tool venv (release#580):
# `release-core init` reads <venv>/release-source.tag (located via sys.prefix)
# to label the managed-sync auto-commit and the .release-sync-source marker
# with the REAL release line — the wheel's package version is a static 0.0.1,
# so without the stamp a consumer's history can't tell v2.16 from v2.17. The
# FILE (not an env var) is the durable channel: a later bare `release-core
# init` (the SessionStart self-sync) runs without this resolver in the chain.
if [ -n "$FROM_SOURCE" ]; then
  # Truthful from-source stamp — never fake a tag: "from-source" plus the
  # source checkout's short HEAD when it is a git tree (release's own CI).
  stamp="from-source"
  if sha="$(git -C "$FROM_SOURCE" rev-parse --short HEAD 2>/dev/null)"; then
    stamp="from-source $sha"
  fi
else
  # The wheel asset URL is .../releases/download/<tag>/<asset> — the resolved
  # release tag is the second-to-last path segment.
  stamp="${source%/*}"; stamp="${stamp##*/}"
fi
printf '%s\n' "$stamp" > "$VENV_DIR/release-source.tag"

# Expose the venv's console-scripts on PATH (symlink into BIN_DIR), so the gate
# and the user's shell find `release-core`, `changelog`, `changelog-render`, …
# without the tool venv itself being on PATH. Skip the venv's own internals.
mkdir -p "$BIN_DIR"
for _s in "$VENV_DIR"/bin/*; do
  [ -f "$_s" ] || [ -L "$_s" ] || continue  # files + symlinks only (skip dirs / empty-glob '*')
  _name="$(basename "$_s")"
  case "$_name" in
    python*|pip*|activate*|Activate.ps1|wheel|*.py) continue ;;
  esac
  ln -sf "$_s" "$BIN_DIR/$_name"
done

# Make the exposed scripts reachable — ONE place, every caller. BIN_DIR onto PATH
# for anything this process still does, and, under GitHub Actions, persisted to
# $GITHUB_PATH for later steps. This is the only environment-specific bit, and it
# lives in the script (not re-scripted in each caller / CI yaml): a CI caller just
# invokes the resolver and gets working console-scripts, same as a local session.
case ":$PATH:" in *":$BIN_DIR:"*) : ;; *) PATH="$BIN_DIR:$PATH"; export PATH ;; esac
[ -n "${GITHUB_PATH:-}" ] && printf '%s\n' "$BIN_DIR" >> "$GITHUB_PATH"

if [ "$NO_INIT" -eq 1 ]; then
  echo "install-release-core: installed (--no-init; skipping release-core init)." >&2
  exit 0
fi

# init is best-effort: a failure (e.g. an older published wheel, or a Kind that
# composes no config) must NOT fail the resolver — the install succeeded, and the
# committed tree degrades gracefully. Runs in the CURRENT repo (the boot caller
# cd's to the repo root first).
# A bare `release-core init` is the DEFAULT full materialize (#476 cutover): it
# materializes the WHOLE managed tree from the wheel bundle (the .release/ build
# dir + every working-tree mirror — skills, ORIENTATION, configs, per-Kind files,
# the CLAUDE.md block) and AUTO-COMMITS ONLY the managed paths it wrote, iff they
# actually changed — never `git add -A`, never a user's other staged/unstaged
# work, no-op when byte-identical. This is the pull-model self-sync: it carries
# the whole tree, so no push is needed (there is no push mechanism). We pass NO
# flags (auto-commit is the default; do NOT pass --commit — it is redundant and
# rejected in the default full mode) and NOT --push: the commit is made locally
# (riding the current branch, excluded from review as a managed change), and
# SessionStart shouldn't push on the consumer's behalf. init stays best-effort — a
# commit/init failure does NOT fail the resolver.
rc="$VENV_DIR/bin/release-core"
if [ -x "$rc" ]; then
  echo "install-release-core: installed; running release-core init (full materialize)" >&2
  "$rc" init || echo "warning: release-core init failed — managed tree not refreshed" >&2
else
  echo "warning: release-core not found in the tool venv ($rc) — skipping init" >&2
fi
