#!/usr/bin/env bash
# Release plugins in this marketplace.
#
# Two modes:
#
#   scripts/release.sh <patch|minor|major|X.Y.Z>
#     Global release. Every plugin bumps together to the same version.
#     - patch: every plugin -> major.minor.(max_patch + 1).
#     - minor: every plugin -> major.(minor + 1).0.
#     - major: every plugin -> (major + 1).0.0.
#     - X.Y.Z: every plugin -> X.Y.Z (must be ahead of current state).
#     Resynchronizes patch versions across plugins if they had diverged.
#
#   scripts/release.sh --plugin <name> patch
#     Per-plugin release. Only <name> bumps; only its source.ref in
#     marketplace.json moves. Other plugins keep pointing at their
#     previous tag, so consumers do not redownload them.
#     Only `patch` is allowed in this mode. Major/minor changes affect
#     the whole marketplace and must go through a global release.
#
# Invariant: every plugin must share the same major.minor. Patch values
# may differ between plugins. The script aborts if major.minor drifts.
#
# Both modes:
#   - Must run from `main` with a clean working tree.
#   - Produce exactly one commit and one annotated tag vX.Y.Z.
#   - Never push.
#
# The JSON parsing, SemVer math and JSON rewriting are done in Node (not
# Python): Node is already required by the Claude Code plugin ecosystem, emits
# LF newlines on every platform, and has no `python3`-vs-`python` ambiguity on
# Windows (where `python3` is often a non-functional Microsoft Store stub).

set -euo pipefail

usage() {
  cat <<USG >&2
Usage:
  $0 <patch|minor|major|X.Y.Z>             Global release.
  $0 --plugin <name> patch                 Per-plugin release.
USG
  exit 64
}

PLUGIN=""
BUMP=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --plugin)
      [[ $# -ge 2 ]] || usage
      PLUGIN="$2"
      shift 2
      ;;
    -h|--help)
      usage
      ;;
    *)
      if [[ -n "$BUMP" ]]; then usage; fi
      BUMP="$1"
      shift
      ;;
  esac
done

[[ -n "$BUMP" ]] || usage

if [[ -n "$PLUGIN" && "$BUMP" != "patch" ]]; then
  echo "release: --plugin <name> only supports 'patch'. For minor/major do a global release." >&2
  exit 64
fi

REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"

# Node does the JSON/version work below.
if ! command -v node >/dev/null 2>&1; then
  echo "release: node is required but was not found on PATH." >&2
  exit 1
fi

CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$CURRENT_BRANCH" != "main" ]]; then
  echo "release: must run from 'main' (currently on '$CURRENT_BRANCH')." >&2
  exit 1
fi

if [[ -n "$(git status --porcelain)" ]]; then
  echo "release: working tree is not clean. Commit or stash first." >&2
  exit 1
fi

# Collect all plugin manifests under plugins/ (newline-separated).
PLUGIN_MANIFESTS="$(find plugins -type f -path "*/.claude-plugin/plugin.json" | sort)"

if [[ -z "$PLUGIN_MANIFESTS" ]]; then
  echo "release: no plugin manifests found under plugins/*/.claude-plugin/plugin.json" >&2
  exit 1
fi

# Plugin source urls must be SSH. Claude Code installs run with terminal prompts
# disabled, so an HTTPS url against the private GitLab host would hang asking for
# credentials. Take the origin url and force it to SSH form, regardless of how
# this particular clone's `origin` happens to be configured (HTTPS or SSH).
URL="$(git remote get-url origin)"
case "$URL" in
  https://*)
    rest="${URL#https://}"   # host/group/repo(.git)
    host="${rest%%/*}"       # host
    path="${rest#*/}"        # group/repo(.git)
    path="${path%.git}"      # group/repo
    URL="git@${host}:${path}.git"
    ;;
esac
[[ "$URL" == *.git ]] || URL="${URL}.git"

# --- Plan: validate the invariant and compute the next version (Node). --------
# Inputs come in via env. Output on stdout: line 1 = next version, line 2 = mode
# (global|individual), lines 3+ = the plugin.json paths to bump.
PLAN_OUT="$(BUMP="$BUMP" TARGET="$PLUGIN" MANIFESTS="$PLUGIN_MANIFESTS" node <<'NODE'
const fs = require('fs');
const bump = process.env.BUMP || '';
const target = process.env.TARGET || '';
const manifestPaths = (process.env.MANIFESTS || '').split('\n').map((s) => s.trim()).filter(Boolean);

const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const die = (m) => { process.stderr.write('release: ' + m + '\n'); process.exit(1); };
const parse = (v) => {
  const m = VERSION_RE.exec(v);
  if (!m) die('cannot parse version: ' + JSON.stringify(v));
  return [Number(m[1]), Number(m[2]), Number(m[3])];
};
const fmt = (t) => t[0] + '.' + t[1] + '.' + t[2];
const cmp = (a, b) => { for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] - b[i]; } return 0; };

const plugins = manifestPaths.map((p) => {
  const data = JSON.parse(fs.readFileSync(p, 'utf8'));
  if (!data.name || !data.version) die(p + " missing 'name' or 'version'.");
  return { path: p, name: data.name, version: data.version, parsed: parse(data.version) };
});

const majorsMinors = new Set(plugins.map((p) => p.parsed[0] + '.' + p.parsed[1]));
if (majorsMinors.size > 1) {
  die('plugins disagree on major.minor -- ' + plugins.map((p) => p.name + '=' + p.version).join(', ') +
      '. Fix them by hand or do a global release.');
}
const [sharedMajor, sharedMinor] = [...majorsMinors][0].split('.').map(Number);

let nextParsed;
let mode;
let bumped;
if (target) {
  const sel = plugins.find((p) => p.name === target);
  if (!sel) die('plugin ' + JSON.stringify(target) + ' not found. Available: ' + plugins.map((p) => p.name).join(', ') + '.');
  nextParsed = [sharedMajor, sharedMinor, sel.parsed[2] + 1];
  mode = 'individual';
  bumped = [sel];
} else {
  const maxPatch = Math.max(...plugins.map((p) => p.parsed[2]));
  if (bump === 'patch') nextParsed = [sharedMajor, sharedMinor, maxPatch + 1];
  else if (bump === 'minor') nextParsed = [sharedMajor, sharedMinor + 1, 0];
  else if (bump === 'major') nextParsed = [sharedMajor + 1, 0, 0];
  else if (VERSION_RE.test(bump)) {
    nextParsed = parse(bump);
    for (const p of plugins) {
      if (cmp(nextParsed, p.parsed) <= 0) die('explicit version ' + bump + ' is not ahead of ' + p.name + ' (' + p.version + ').');
    }
  } else {
    die('invalid bump or version: ' + JSON.stringify(bump) + '.');
  }
  mode = 'global';
  bumped = plugins;
}

process.stdout.write([fmt(nextParsed), mode].concat(bumped.map((b) => b.path)).join('\n') + '\n');
NODE
)"

NEXT_VERSION="$(printf '%s\n' "$PLAN_OUT" | sed -n '1p')"
MODE="$(printf '%s\n' "$PLAN_OUT" | sed -n '2p')"
BUMP_PATHS="$(printf '%s\n' "$PLAN_OUT" | sed -n '3,$p')"
TAG="v${NEXT_VERSION}"

if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then
  echo "release: tag $TAG already exists." >&2
  exit 1
fi

if [[ "$MODE" == "individual" ]]; then
  echo "release: per-plugin bump for ${PLUGIN}; new tag ${TAG} (other plugins keep their existing refs)"
else
  echo "release: global bump to ${NEXT_VERSION}; new tag ${TAG} (all plugins resynchronized to ${NEXT_VERSION})"
fi

# --- Apply: rewrite the bumped plugin.json files and marketplace.json (Node). -
BUMP_PATHS="$BUMP_PATHS" NEXT_VERSION="$NEXT_VERSION" TAG="$TAG" URL="$URL" node <<'NODE'
const fs = require('fs');
const bumpPaths = (process.env.BUMP_PATHS || '').split('\n').map((s) => s.trim()).filter(Boolean);
const nextVersion = process.env.NEXT_VERSION;
const tag = process.env.TAG;
const url = process.env.URL;
const die = (m) => { process.stderr.write('release: ' + m + '\n'); process.exit(1); };
const writeJson = (p, data) => fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8');

const bumpedNames = new Set();
for (const p of bumpPaths) {
  const data = JSON.parse(fs.readFileSync(p, 'utf8'));
  bumpedNames.add(data.name);
  data.version = nextVersion;
  writeJson(p, data);
}

const mpPath = '.claude-plugin/marketplace.json';
const mp = JSON.parse(fs.readFileSync(mpPath, 'utf8'));
if (mp.metadata && typeof mp.metadata === 'object' && 'version' in mp.metadata) {
  mp.metadata.version = nextVersion;
}
for (const entry of (mp.plugins || [])) {
  const source = entry.source;
  if (!source || typeof source !== 'object' || source.source !== 'git-subdir') {
    die('plugin ' + JSON.stringify(entry.name) + ' does not use a git-subdir source. Fix marketplace.json or extend release.sh.');
  }
  if (bumpedNames.has(entry.name)) {
    if ('version' in entry) entry.version = nextVersion;
    source.url = url;
    source.ref = tag;
  }
}
writeJson(mpPath, mp);
NODE

# Stage every file that the bump touched, then commit + tag.
git add .claude-plugin/marketplace.json
for bump_path in $BUMP_PATHS; do
  git add "$bump_path"
done

if [[ "$MODE" == "individual" ]]; then
  COMMIT_MSG="chore(release): ${PLUGIN} ${TAG}"
  TAG_MSG="Release ${TAG} (${PLUGIN} only)"
else
  COMMIT_MSG="chore(release): ${TAG}"
  TAG_MSG="Release ${TAG}"
fi

git commit -m "$COMMIT_MSG"
git tag -a "$TAG" -m "$TAG_MSG"

cat <<EOF

release: prepared ${TAG} locally (${MODE}).

Inspect:
  git show ${TAG} -- .claude-plugin/marketplace.json
  git show ${TAG} -- plugins/*/.claude-plugin/plugin.json

Publish (run these yourself, the script never pushes):
  git push origin main
  git push origin ${TAG}
EOF
