#!/usr/bin/env bash
# release-desktop.sh — one-command desktop release (Mac + Windows + Linux)
#
# Usage: bash scripts/release/release-desktop.sh <version>
# Example: bash scripts/release/release-desktop.sh 1.0.27
#
# What this script does (end-to-end, no manual steps):
#   1.  Verifies the on-disk minisign keypair signs successfully.
#   2.  Re-uploads TAURI_SIGNING_PRIVATE_KEY (clean base64) to GH secrets.
#       Passphrase secret is set to empty (the key has no passphrase — see
#       "Why no passphrase" below).
#   3.  Bumps src-tauri/Cargo.toml + tauri.conf.json + Cargo.lock + the
#       dashboard /desktop page constants.
#   4.  Commits + pushes main + tags desktop-v<version>.
#   5.  Polls CI until completion (default max 2 hours).
#   6.  On green:
#        - downloads the new minisign .sig files
#        - rewrites vskill-platform's latest.json route + /desktop page
#        - rebuilds vskill-platform Worker bundle + redeploys to Cloudflare
#        - promotes the GH release from draft → published
#        - cleans up any stale failed-release drafts
#        - prints the live release URL
#
# Why this specific keypair:
#   We tried v1.0.13 → v1.0.27 with two different keypairs:
#   - v1.0.13..v1.0.26: passphrase-encrypted key generated by `minisign` (BSD CLI).
#     tauri-action's Rust minisign rejected every passphrase encoding we tried,
#     even when GH-stored bytes matched expected SHA-256 exactly.
#   - v1.0.27: no-passphrase key generated by `minisign -G -W`.
#     Rust minisign reads the key as "not encrypted" but its sign() code path
#     STILL requires a password for encrypted-format keys.
#   - v1.0.28+: keypair generated by `rsign` (Rust minisign CLI, same crate
#     tauri-action uses). End-to-end verified locally before tagging.
#
# Persisted state on Anton's Mac (do NOT re-ask):
#   ~/.config/skill-studio-release/minisign.key         (mode 0600, encrypted)
#   ~/.config/skill-studio-release/minisign.passphrase  (mode 0600, 32 chars alphanumeric)
#   ~/.config/skill-studio-release/minisign.pub         (RWRAKMUtAimeDjbhUiZU5OMP0tj7dOo3OPPy62n65bXLD94IdM1wk7DS)
#   Old broken keypairs archived as `*.OLD-*.bak`.
#
# Pubkey baked into src-tauri/tauri.conf.json must match minisign.pub.
# Source-of-truth note: Obsidian vault →
#   003 Resources/Technical Knowledge/Credentials-Secrets-Passwords/EasyChamp/
#     Skill Studio Desktop release credentials.md

set -euo pipefail

if [[ $# -ne 1 ]]; then
  echo "usage: $0 <version>   # e.g. 1.0.27" >&2
  exit 1
fi

VERSION="$1"
TAG="desktop-v${VERSION}"
REPO="anton-abyzov/vskill"
KEY_DIR="${HOME}/.config/skill-studio-release"
PLATFORM_REPO_REL="repositories/anton-abyzov/vskill-platform"
CI_TIMEOUT_SECONDS="${CI_TIMEOUT_SECONDS:-7200}"

# ─── 0. Pre-flight ────────────────────────────────────────────────────────
[[ -f "${KEY_DIR}/minisign.key" ]] || { echo "❌ missing ${KEY_DIR}/minisign.key" >&2; exit 1; }
[[ -f "${KEY_DIR}/minisign.pub" ]] || { echo "❌ missing ${KEY_DIR}/minisign.pub" >&2; exit 1; }
[[ -f "${KEY_DIR}/minisign.passphrase" ]] || { echo "❌ missing ${KEY_DIR}/minisign.passphrase" >&2; exit 1; }
command -v minisign >/dev/null || { echo "❌ minisign not in PATH (brew install minisign)" >&2; exit 1; }
command -v gh >/dev/null || { echo "❌ gh not in PATH (brew install gh)" >&2; exit 1; }
command -v node >/dev/null || { echo "❌ node not in PATH" >&2; exit 1; }
gh auth status >/dev/null 2>&1 || { echo "❌ gh not authenticated (gh auth login)" >&2; exit 1; }

# ─── 1. Verify on-disk keypair signs ──────────────────────────────────────
echo ">> [1/8] verifying on-disk minisign keypair signs..."
echo "test data" > /tmp/_release_check.txt
PASSPHRASE=$(cat "${KEY_DIR}/minisign.passphrase")
printf '%s' "${PASSPHRASE}" | minisign -S -s "${KEY_DIR}/minisign.key" -m /tmp/_release_check.txt > /dev/null 2>&1 \
  || { echo "❌ minisign sign failed — passphrase doesn't unlock the key. Inspect ${KEY_DIR}/." >&2; unset PASSPHRASE; exit 1; }
rm -f /tmp/_release_check.txt /tmp/_release_check.txt.minisig
echo "   ✅ keypair OK"

# Verify pubkey in tauri.conf.json is in the CORRECT FORMAT.
# Tauri expects: base64(FULL minisign.pub file contents) — NOT the raw key string.
# This bug burned 16 CI runs (v1.0.13..v1.0.28) before being identified.
# Older tauri-bundler versions silently fell through to signing and surfaced
# a misleading "Wrong password for that key" error; v1.0.28's tauri-bundler
# finally produced the true error: "failed to decode pubkey: failed to
# convert base64 to utf8".
REPO_ROOT="$(git rev-parse --show-toplevel)"
EXPECTED_PUBKEY=$(base64 -i "${KEY_DIR}/minisign.pub" | tr -d '\n\r\t ')
CONF_PUBKEY=$(node -e "const fs=require('fs'); console.log(JSON.parse(fs.readFileSync('${REPO_ROOT}/src-tauri/tauri.conf.json','utf8')).plugins.updater.pubkey);")
if [[ "${CONF_PUBKEY}" != "${EXPECTED_PUBKEY}" ]]; then
  echo "❌ tauri.conf.json plugins.updater.pubkey is WRONG FORMAT or out of date." >&2
  echo "   Expected (base64 of full minisign.pub):" >&2
  echo "     ${EXPECTED_PUBKEY}" >&2
  echo "   Found:" >&2
  echo "     ${CONF_PUBKEY}" >&2
  echo "" >&2
  echo "   To fix, update tauri.conf.json plugins.updater.pubkey to the expected value." >&2
  echo "   (Common mistake: putting the raw key string '${EXPECTED_PUBKEY:0:8}...' here.)" >&2
  exit 1
fi
echo "   ✅ pubkey in tauri.conf.json is base64(minisign.pub) form (correct)"

# ─── 2. Upload TAURI_SIGNING_PRIVATE_KEY + passphrase ────────────────────
echo ">> [2/8] uploading TAURI_SIGNING_PRIVATE_KEY (base64) + passphrase..."
KEY_B64=$(base64 -i "${KEY_DIR}/minisign.key" | tr -d '\n\r\t ')
printf '%s' "${KEY_B64}" | gh secret set TAURI_SIGNING_PRIVATE_KEY --repo "${REPO}" >/dev/null
unset KEY_B64
printf '%s' "${PASSPHRASE}" | gh secret set TAURI_SIGNING_PRIVATE_KEY_PASSWORD --repo "${REPO}" >/dev/null
unset PASSPHRASE
echo "   ✅ secrets uploaded"

# ─── 3. Bump versions ─────────────────────────────────────────────────────
cd "${REPO_ROOT}"
echo ">> [3/8] bumping versions to ${VERSION}..."
CURRENT=$(grep '^version = ' src-tauri/Cargo.toml | head -1 | sed -E 's/version = "([^"]+)".*/\1/')
sed -i.bak -E "s/^version = \"${CURRENT}\"$/version = \"${VERSION}\"/" src-tauri/Cargo.toml
rm -f src-tauri/Cargo.toml.bak
node -e "const fs=require('fs'); const p='src-tauri/tauri.conf.json'; const c=JSON.parse(fs.readFileSync(p,'utf8')); c.version='${VERSION}'; fs.writeFileSync(p, JSON.stringify(c, null, 2)+'\n');"
(cd src-tauri && cargo update -p vskill-desktop --precise "${VERSION}" >/dev/null 2>&1)
echo "   ✅ ${CURRENT} → ${VERSION}"

# ─── 4. Commit + push + tag ───────────────────────────────────────────────
echo ">> [4/8] committing + pushing + tagging..."
git add src-tauri/Cargo.toml src-tauri/tauri.conf.json src-tauri/Cargo.lock
if git diff --cached --quiet; then
  echo "   (no version-file changes; pushing tag against existing HEAD)"
else
  git commit -m "release: desktop ${VERSION}" >/dev/null
  git push origin main >/dev/null
fi
git tag "${TAG}" 2>/dev/null || true
git push origin "${TAG}" >/dev/null
echo "   ✅ tag ${TAG} pushed"

# ─── 5. Wait for CI ────────────────────────────────────────────────────────
sleep 8
RUN_ID=$(gh run list -R "${REPO}" --workflow="Desktop Release" --limit 20 --json databaseId,headBranch --jq ".[] | select(.headBranch == \"${TAG}\") | .databaseId" | head -1)
[[ -n "${RUN_ID}" ]] || { echo "❌ no Desktop Release CI run found for ${TAG}" >&2; exit 1; }
echo ">> [5/8] CI run: https://github.com/${REPO}/actions/runs/${RUN_ID}"
echo "   polling (up to $((CI_TIMEOUT_SECONDS / 60)) min)..."
START=$(date +%s)
until [[ "$(gh run view "${RUN_ID}" -R "${REPO}" --json status --jq .status)" = "completed" ]]; do
  ELAPSED=$(( $(date +%s) - START ))
  if (( ELAPSED > CI_TIMEOUT_SECONDS )); then
    echo "❌ CI timeout"
    exit 1
  fi
  printf '.'
  sleep 15
done
echo
CONCLUSION=$(gh run view "${RUN_ID}" -R "${REPO}" --json conclusion --jq .conclusion)
if [[ "${CONCLUSION}" != "success" ]]; then
  echo "❌ CI ${CONCLUSION}: see https://github.com/${REPO}/actions/runs/${RUN_ID}"
  exit 1
fi
echo "   ✅ CI green"

# ─── 6. Update vskill-platform manifest ───────────────────────────────────
echo ">> [6/8] updating vskill-platform manifest..."
RELEASE_TMP="/tmp/release-${VERSION}"
mkdir -p "${RELEASE_TMP}"
rm -f "${RELEASE_TMP}"/*.sig
gh release download "${TAG}" --repo "${REPO}" \
  --pattern '*aarch64*.app.tar.gz.sig' \
  --pattern '*x64-setup.exe.sig' \
  --pattern '*amd64.AppImage.sig' \
  --dir "${RELEASE_TMP}" >/dev/null

read_sig() {
  local pattern="$1"
  local file
  file="$(find "${RELEASE_TMP}" -type f -name "${pattern}" | head -1)"
  [[ -f "${file}" ]] || { echo "❌ missing release signature matching ${pattern}" >&2; exit 1; }
  tr -d '\n\r' < "${file}"
}

DARWIN_SIG="$(read_sig '*aarch64*.app.tar.gz.sig')"
WINDOWS_SIG="$(read_sig '*x64-setup.exe.sig')"
LINUX_SIG="$(read_sig '*amd64.AppImage.sig')"
RELEASE_NOTES="$(gh release view "${TAG}" -R "${REPO}" --json body --jq .body 2>/dev/null || true)"
if [[ -z "${RELEASE_NOTES//[[:space:]]/}" ]]; then
  RELEASE_NOTES="Skill Studio v${VERSION}"
fi
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

PLATFORM_DIR="${REPO_ROOT}/../../${PLATFORM_REPO_REL}"
if [[ ! -d "${PLATFORM_DIR}" ]]; then
  # fallback for non-umbrella checkouts
  PLATFORM_DIR=$(find "${REPO_ROOT}/.." -maxdepth 4 -type d -name vskill-platform 2>/dev/null | head -1)
  [[ -z "${PLATFORM_DIR}" ]] && { echo "❌ can't find vskill-platform repo"; exit 1; }
fi

ROUTE="${PLATFORM_DIR}/src/app/desktop/latest.json/route.ts"
PAGE="${PLATFORM_DIR}/src/app/desktop/page.tsx"
[[ -f "${ROUTE}" ]] || { echo "❌ missing ${ROUTE}" >&2; exit 1; }
[[ -f "${PAGE}" ]] || { echo "❌ missing ${PAGE}" >&2; exit 1; }

RELEASE_VERSION="${VERSION}" \
ROUTE_PATH="${ROUTE}" \
PAGE_PATH="${PAGE}" \
PUB_DATE="${PUB_DATE}" \
DARWIN_SIG="${DARWIN_SIG}" \
WINDOWS_SIG="${WINDOWS_SIG}" \
LINUX_SIG="${LINUX_SIG}" \
RELEASE_NOTES="${RELEASE_NOTES}" \
node <<'NODE'
const fs = require("fs");

const version = process.env.RELEASE_VERSION;
const pubDate = process.env.PUB_DATE;
const notes = process.env.RELEASE_NOTES;

function replaceRequired(source, pattern, replacement, label) {
  const next = source.replace(pattern, replacement);
  if (next === source) {
    throw new Error(`failed to update ${label}`);
  }
  return next;
}

function replaceConst(source, name, value) {
  return replaceRequired(
    source,
    new RegExp(`const ${name} =\\s*\\n?\\s*"[^"]+";`, "s"),
    `const ${name} =\n  ${JSON.stringify(value)};`,
    name,
  );
}

const routePath = process.env.ROUTE_PATH;
let route = fs.readFileSync(routePath, "utf8");
route = replaceRequired(route, /desktop-v[0-9]+\.[0-9]+\.[0-9]+/g, `desktop-v${version}`, "release base");
route = replaceRequired(route, /Skill\.Studio_[0-9]+\.[0-9]+\.[0-9]+_x64-setup\.exe/g, `Skill.Studio_${version}_x64-setup.exe`, "windows asset URL");
route = replaceRequired(route, /Skill\.Studio_[0-9]+\.[0-9]+\.[0-9]+_amd64\.AppImage/g, `Skill.Studio_${version}_amd64.AppImage`, "linux asset URL");
route = replaceConst(route, "DARWIN_AARCH64_SIGNATURE", process.env.DARWIN_SIG);
route = replaceConst(route, "WINDOWS_X86_64_SIGNATURE", process.env.WINDOWS_SIG);
route = replaceConst(route, "LINUX_X86_64_SIGNATURE", process.env.LINUX_SIG);
route = replaceRequired(route, /version: "[0-9]+\.[0-9]+\.[0-9]+"/, `version: ${JSON.stringify(version)}`, "manifest version");
route = replaceRequired(route, /notes:\s*"(?:[^"\\]|\\.)*"/s, `notes: ${JSON.stringify(notes)}`, "release notes");
route = replaceRequired(route, /pub_date: "[^"]+"/, `pub_date: ${JSON.stringify(pubDate)}`, "pub_date");
fs.writeFileSync(routePath, route);

const pagePath = process.env.PAGE_PATH;
let page = fs.readFileSync(pagePath, "utf8");
page = replaceRequired(page, /LATEST_VERSION = "[0-9]+\.[0-9]+\.[0-9]+"/, `LATEST_VERSION = ${JSON.stringify(version)}`, "page version");
page = replaceRequired(page, /RELEASE_TAG = "desktop-v[0-9]+\.[0-9]+\.[0-9]+"/, `RELEASE_TAG = ${JSON.stringify(`desktop-v${version}`)}`, "page tag");
fs.writeFileSync(pagePath, page);
NODE
unset DARWIN_SIG WINDOWS_SIG LINUX_SIG RELEASE_NOTES

# v1.0.47 lesson: the node block above said "updated" but the disk was clean
# when step [7/8] reached `npm run build`. Fail fast here if the writes did not
# actually land on the platform repo working tree.
if [[ -d "${PLATFORM_DIR}/.git" ]]; then
  ROUTE_DIFF=$(git -C "${PLATFORM_DIR}" diff --shortstat -- src/app/desktop/latest.json/route.ts 2>/dev/null || true)
  PAGE_DIFF=$(git -C "${PLATFORM_DIR}" diff --shortstat -- src/app/desktop/page.tsx 2>/dev/null || true)
  if [[ -z "${ROUTE_DIFF}" && -z "${PAGE_DIFF}" ]]; then
    echo "❌ manifest+dashboard files not modified on disk after node write." >&2
    echo "   route: ${ROUTE}" >&2
    echo "   page:  ${PAGE}" >&2
    echo "   git -C ${PLATFORM_DIR} status -s:" >&2
    git -C "${PLATFORM_DIR}" status -s -- src/app/desktop/ >&2 || true
    exit 1
  fi
  echo "   ✅ manifest + dashboard updated (route:${ROUTE_DIFF:-clean} page:${PAGE_DIFF:-clean})"
else
  echo "   ✅ manifest + dashboard updated (platform repo not git — skipped diff verify)"
fi

# ─── 7. Rebuild + redeploy vskill-platform ────────────────────────────────
echo ">> [7/8] rebuilding + redeploying vskill-platform to Cloudflare..."
(
  cd "${PLATFORM_DIR}"
  # v1.0.47 lesson: Node 20 makes Next.js's `Collecting build traces` phase
  # silently drop *.nft.json files, then `npx @opennextjs/cloudflare build`
  # crashes with ENOENT. Node 22 works. Force it here regardless of the
  # interactive shell's `nvm use`.
  if [[ -s "${HOME}/.nvm/nvm.sh" ]]; then
    # shellcheck disable=SC1091
    . "${HOME}/.nvm/nvm.sh" >/dev/null 2>&1
    nvm use 22 >/dev/null 2>&1 || echo "⚠️  nvm use 22 failed — proceeding with $(node --version)"
  fi
  echo "   using node $(node --version)"
  rm -rf .next .open-next
  npm run build >/tmp/release-${VERSION}/build.log 2>&1 || { echo "❌ npm run build failed (see /tmp/release-${VERSION}/build.log)"; exit 1; }
  npm run build:worker >/tmp/release-${VERSION}/build-worker.log 2>&1 || { echo "❌ npm run build:worker failed (see /tmp/release-${VERSION}/build-worker.log)"; exit 1; }
  if [[ -z "${CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE:-}" && -f .env ]]; then
    LOCAL_HYPERDRIVE_URL="$(node -e "const fs=require('fs'); const text=fs.readFileSync('.env','utf8'); const line=text.split(/\\n/).find((entry)=>entry.startsWith('DATABASE_URL=')); if(!line) process.exit(0); let value=line.slice('DATABASE_URL='.length).trim(); if((value.startsWith('\\\"')&&value.endsWith('\\\"'))||(value.startsWith(\"'\")&&value.endsWith(\"'\"))) value=value.slice(1,-1); try{new URL(value); process.stdout.write(value);}catch{process.exit(0);}")"
    if [[ -n "${LOCAL_HYPERDRIVE_URL}" ]]; then
      export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="${LOCAL_HYPERDRIVE_URL}"
    fi
    unset LOCAL_HYPERDRIVE_URL
  fi
  npm run deploy >/tmp/release-${VERSION}/deploy.log 2>&1 || { echo "❌ npm run deploy failed"; exit 1; }
)
sleep 5
LIVE_VERSION=$(curl -fsSL https://verified-skill.com/desktop/latest.json 2>/dev/null | node -e "let s=''; process.stdin.on('data',c=>s+=c); process.stdin.on('end',()=>console.log(JSON.parse(s).version))" 2>/dev/null || echo "?")
if [[ "${LIVE_VERSION}" != "${VERSION}" ]]; then
  echo "❌ live manifest still shows ${LIVE_VERSION}, expected ${VERSION}"
  exit 1
fi
echo "   ✅ verified-skill.com/desktop/latest.json now serves ${VERSION}"

# ─── 8. Promote release + cleanup stale drafts ───────────────────────────
echo ">> [8/8] promoting release + cleanup..."
gh release edit "${TAG}" --draft=false -R "${REPO}" >/dev/null
# Cleanup any stale failed-release drafts (preserve only the live one)
STALE=$(gh release list -R "${REPO}" --json tagName,isDraft --jq '.[] | select(.isDraft == true) | select(.tagName | startswith("desktop-v")) | .tagName')
for t in ${STALE}; do
  if [[ "${t}" != "${TAG}" ]]; then
    gh release delete "${t}" -R "${REPO}" -y --cleanup-tag >/dev/null 2>&1 || true
  fi
done
echo "   ✅ promoted + drafts cleaned"

# Commit the manifest update from the platform repo when it is independently cloned.
if [[ -d "${PLATFORM_DIR}/.git" ]]; then
  (
    cd "${PLATFORM_DIR}"
    git add src/app/desktop/latest.json/route.ts src/app/desktop/page.tsx
    if ! git diff --cached --quiet; then
      git commit -m "release: desktop ${VERSION} platform manifest" >/dev/null
      git push origin main >/dev/null 2>&1 || true
    fi
  )
else
  # Fallback for monorepo checkouts that track vskill-platform from the umbrella root.
  UMBRELLA_ROOT=$(cd "${REPO_ROOT}/../../.." && pwd)
  if [[ -d "${UMBRELLA_ROOT}/.git" ]]; then
  (
    cd "${UMBRELLA_ROOT}"
    git add "${PLATFORM_REPO_REL}/src/app/desktop/latest.json/route.ts" "${PLATFORM_REPO_REL}/src/app/desktop/page.tsx" 2>/dev/null || true
    if ! git diff --cached --quiet; then
      git commit -m "release: desktop ${VERSION} manifest update" >/dev/null
      git push origin main >/dev/null 2>&1 || true
    fi
  )
  fi
fi

echo ""
echo "╔════════════════════════════════════════════════════════════╗"
echo "║  ✅ DESKTOP RELEASE ${VERSION} LIVE                       "
echo "╠════════════════════════════════════════════════════════════╣"
echo "║  GitHub:  https://github.com/${REPO}/releases/tag/${TAG}"
echo "║  Manifest: https://verified-skill.com/desktop/latest.json"
echo "║  Page:    https://verified-skill.com/desktop"
echo "╚════════════════════════════════════════════════════════════╝"
