#!/usr/bin/env bash
# PR #23250 (fix(server): retain the runtime used by the service launcher), fixes #17490.
# Claim: already-fixed. Main keeps the runtime the installed service definition starts when it prunes
# (the unit's program is passed as a prune reference, #22253/#25870), and #19652 now repoints the unit
# at the committed runtime on every remote update, so the A -> B -> C restart failure cannot recur.
# Shows: a focused test of the PR's scenario passes on main and fails when main stops passing the unit
# as a prune reference; main's own repoint test passes; #17490 was closed by merged #19652.
# Needs deps installed (`vp i`) and `gh` authenticated. Run from the root of a clean t3code checkout.
set -euo pipefail

REPO=pingdotgg/t3code
ok=1
pass() { echo "PASS  $*"; }
fail() { echo "FAIL  $*"; ok=0; }

if [ -n "$(git status --porcelain)" ]; then
  echo "refusing to run on a dirty tree"; exit 2
fi
ORIG=$(git symbolic-ref -q --short HEAD || git rev-parse HEAD)
WORK=$(mktemp -d)
PROOF_TEST=apps/server/src/cloud/pr23250Proof.test.ts
cleanup() {
  rm -f "$PROOF_TEST"
  git checkout -q -f -- . 2>/dev/null || true
  git checkout -q -f "$ORIG"
  rm -rf "$WORK"
}
trap 'rc=$?; cleanup; [ "${PROOF_DONE:-0}" = 1 ] && exit 0; [ "$rc" -ne 0 ] && exit "$rc"; exit 1' EXIT

git fetch -q origin main

MAIN=$(git rev-parse FETCH_HEAD)
git fetch -q origin pull/23250/head
PR_HEAD=$(git rev-parse FETCH_HEAD)
MB=$(git merge-base "$MAIN" "$PR_HEAD")
echo "main=$MAIN pr_head=$PR_HEAD merge_base=$MB"

# 1. Issue and superseding PR.
issue=$(gh api "repos/$REPO/issues/17490" --jq '"\(.state) \(.state_reason)"')
closer=$(gh api graphql -f query='{repository(owner:"pingdotgg",name:"t3code"){issue(number:17490){timelineItems(itemTypes:CLOSED_EVENT,last:1){nodes{...on ClosedEvent{closer{...on PullRequest{number merged}}}}}}}}' \
  --jq '.data.repository.issue.timelineItems.nodes[0].closer | "\(.number) \(.merged)"')
if [ "$issue" = "closed completed" ] && [ "$closer" = "19652 true" ]; then
  pass "issue #17490 is closed as completed by merged PR #19652"
else
  fail "issue #17490 state='$issue' closer='$closer'"
fi
sha=$(git log "$MAIN" --format=%h --grep="(#19652)" | head -1)
if [ -n "$sha" ]; then pass "#19652 is on main as $sha ($(git log -1 --format=%s "$sha"))"
else fail "#19652 not found on main"; fi

# 2. Static: the server-start prune at the PR's merge-base passed no references; main passes the unit's program.
if git show "$MB:apps/server/src/cloud/selfUpdate.ts" | grep -q 'prunePinnedRuntimes({ baseDir: serverConfig.baseDir, keep: \[packageJson.version\] })'; then
  pass "at the PR's merge-base the server-start prune passed no unit reference (the bug the PR targets)"
else
  fail "merge-base server-start prune call not as expected"
fi
if git show "$MAIN:apps/server/src/cloud/selfUpdate.ts" | grep -q "A stopped service's unit still names the launcher runtime it will start" \
  && git show "$MAIN:apps/server/src/cli/update.ts" | grep -q "references: Option.toArray(Option.flatMapNullishOr(unit, BootService.bootServiceProgramOf))"; then
  pass "main passes the service definition's program as a prune reference in both prune call sites"
else
  fail "main prune call sites do not reference the service definition"
fi

# 3. Behavioral: the PR's scenario on main.
git checkout -q --detach "$MAIN"
cat > "$PROOF_TEST" <<'EOF'
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as TestClock from "effect/testing/TestClock";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import packageJson from "../../package.json" with { type: "json" };
import * as ProcessRunner from "../processRunner.ts";
import { SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE } from "./serviceProtocol.ts";
import * as ServerSelfUpdate from "./selfUpdate.ts";

// PR #23250's scenario: the service was installed from runtime A, remote updates moved it through B
// to C, and service-state.json names only B and C. A stale runtime nothing references must still go.
const LAUNCHER = "0.0.1";
const PREVIOUS = "0.0.2";
const UNRELATED = "0.0.3";
const CURRENT = packageJson.version;

const noRuntimeProcesses = ProcessRunner.ProcessRunner.of({
  run: () =>
    Effect.succeed({
      stdout: "p42\nftxt\nn/bin/sh\n",
      stderr: "",
      code: ChildProcessSpawner.ExitCode(0),
      timedOut: false,
      stdoutTruncated: false,
      stderrTruncated: false,
      stdoutInvalidUtf8: false,
      stderrInvalidUtf8: false,
    }),
});

const pruneAfterRemoteUpdates = Effect.fn("pr23250.prune")(function* (withServiceDefinition: boolean) {
  const fs = yield* FileSystem.FileSystem;
  const path = yield* Path.Path;
  const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr23250-" });
  const baseDir = path.join(tempDir, "home");
  const userHome = path.join(tempDir, "user");
  const versionsDir = path.join(baseDir, "runtime", "versions");
  yield* TestClock.setTime(24 * 60 * 60 * 1000);
  for (const version of [LAUNCHER, PREVIOUS, UNRELATED, CURRENT]) {
    const directory = path.join(versionsDir, version);
    yield* fs.makeDirectory(directory, { recursive: true });
    yield* fs.writeFileString(path.join(directory, "t3"), "");
    const sentinel = path.join(directory, ".install-complete");
    yield* fs.writeFileString(sentinel, `${version}\n`);
    yield* fs.utimes(sentinel, 0, 0);
  }
  yield* fs.writeFileString(
    path.join(baseDir, "runtime", SERVICE_STATE_FILE),
    JSON.stringify({
      protocol: SERVICE_LAUNCHER_PROTOCOL,
      activeVersion: CURRENT,
      update: { id: "u-2", fromVersion: PREVIOUS, targetVersion: CURRENT, status: "committed" },
    }),
  );
  if (withServiceDefinition) {
    const agentsDir = path.join(userHome, "Library", "LaunchAgents");
    yield* fs.makeDirectory(agentsDir, { recursive: true });
    const program = path.join(versionsDir, LAUNCHER, "t3");
    yield* fs.writeFileString(
      path.join(agentsDir, "com.t3tools.t3code.service.plist"),
      `<plist><dict>\n<key>ProgramArguments</key>\n<array>\n<string>${program}</string>\n<string>__service-launcher</string>\n</array>\n</dict></plist>\n`,
    );
  }
  const removed = yield* ServerSelfUpdate.pruneRuntimesOnStart({ baseDir, managed: true }).pipe(
    Effect.provideService(HostProcess.ExecutablePath, "/usr/local/bin/node"),
    Effect.provideService(HostProcess.Platform, "darwin"),
    Effect.provideService(HostProcess.Environment, {}),
    Effect.provideService(HostProcess.HomeDirectory, userHome),
    Effect.provideService(ProcessRunner.ProcessRunner, noRuntimeProcesses),
  );
  return { removed, remaining: (yield* fs.readDirectory(versionsDir)).toSorted() };
});

it.layer(NodeServices.layer)("PR 23250 claim", (it) => {
  it.effect("pruning keeps the runtime the service definition starts though state names only B and C", () =>
    Effect.gen(function* () {
      const { removed, remaining } = yield* pruneAfterRemoteUpdates(true);
      expect(removed).toEqual([UNRELATED]);
      expect(remaining).toEqual([LAUNCHER, PREVIOUS, CURRENT].toSorted());
    }),
  );

  it.effect("control: without the service definition the launcher runtime is pruned", () =>
    Effect.gen(function* () {
      const { removed } = yield* pruneAfterRemoteUpdates(false);
      expect(removed.toSorted()).toEqual([LAUNCHER, UNRELATED]);
    }),
  );
});
EOF
vp test run --reporter=verbose apps/server/src/cloud/pr23250Proof.test.ts > "$WORK/main.log" 2>&1 || true
grep -E "^ +(✓|×)|Tests +[0-9]" "$WORK/main.log" || tail -30 "$WORK/main.log"
if grep -qE "Tests +2 passed" "$WORK/main.log" && ! grep -qE "Tests +.*failed" "$WORK/main.log"; then
  pass "on main, pruning after A->B->C keeps launcher runtime A (named only by the service definition) and removes the unreferenced runtime; the control without the definition prunes A"
else
  fail "the PR's retention scenario does not hold on main"
fi

# 4. Mutation: stop passing the service definition as a prune reference; the scenario must fail.
perl -0pi -e 's/keep: \[packageJson\.version\],\n(\s*)references,/keep: [packageJson.version],\n$1references: [],/' apps/server/src/cloud/selfUpdate.ts
if git diff --quiet -- apps/server/src/cloud/selfUpdate.ts; then
  fail "mutation did not apply to main's selfUpdate.ts"
else
  vp test run apps/server/src/cloud/pr23250Proof.test.ts > "$WORK/mutant.log" 2>&1 || true
  grep -E "Tests +[0-9]" "$WORK/mutant.log" || true
  if grep -qE "Tests +1 failed \| 1 passed" "$WORK/mutant.log"; then
    pass "with main's unit reference removed, the retention case fails (runtime A is pruned): main's reference is what fixes #17490"
  else
    fail "removing main's unit reference did not make the retention case fail"
  fi
  git checkout -q -f -- apps/server/src/cloud/selfUpdate.ts
fi

# 5. Main's own test: a remote update repoints the service definition at the committed runtime (#19652).
vp test run --reporter=verbose apps/server/src/serviceLauncher.test.ts -t "commits and repoints" > "$WORK/repoint.log" 2>&1 || true
grep -E "^ +(✓|×)|Tests +[0-9]" "$WORK/repoint.log" || tail -30 "$WORK/repoint.log"
if grep -qE "Tests +3 passed" "$WORK/repoint.log" && ! grep -qE "Tests +.*failed" "$WORK/repoint.log"; then
  pass "main repoints systemd, launchd and Task Scheduler definitions at the committed runtime after an update"
else
  fail "main's repoint tests did not all pass"
fi

if [ "$ok" = 1 ]; then
  echo "VERDICT: CLOSE (already-fixed) - main retains the runtime the service definition starts and repoints it on update (#19652, #22253/#25870)"
  PROOF_DONE=1; exit 0
fi
echo "VERDICT: KEEP - at least one check failed"
exit 1
