#!/usr/bin/env node
/**
 * postinstall hook for @c4a/context-cli.
 *
 * The goal is narrow: when someone installs this package as a Claude Code
 * plugin, the `context` bin should end up on PATH so `/context:*` slash
 * commands can Bash out to it. In every other install scenario (library
 * dependency, `bun install -g`, CI, local `bun link`) we do nothing.
 *
 * Dangerous things this script used to do (review findings 高-1 / 高-2):
 *   - Recursion: the child `bun install -g @c4a/context-cli` inherits the
 *     same Claude env vars that triggered us, runs postinstall again, and
 *     loops. Fixed by (a) hard re-entry guard via CONTEXT_CLI_POSTINSTALL
 *     in the child env, and (b) skipping entirely when we are already
 *     inside a global install (`npm_config_global === "true"`).
 *   - Dev mode break: pinning the registry spec `@c4a/context-cli@<version>`
 *     ignores the actual install source (local tarball, file:, github:,
 *     unpublished pre-release). Fixed by never spawning a child install;
 *     we print a single actionable hint with the registry fallback command.
 *     The plugin tarball already contains the whole CLI, so the user can
 *     just `bun install -g <same source>` themselves — we can't guess the
 *     source reliably from inside postinstall.
 *
 * Structure: all decision logic lives in the pure, exported `decide()`
 * function. The top-level side-effect wrapper is the only thing that reads
 * `process.env`, spawns the PATH probe, or writes to stderr. This split
 * lets a sibling `.test.ts` exercise every branch without spawning
 * subprocesses (Bun test's subprocess sandboxing has been unreliable in
 * this package, so in-process tests are the practical way to cover the
 * script).
 */

import { spawnSync } from "node:child_process";
import { realpathSync } from "node:fs";
import process from "node:process";
import { pathToFileURL } from "node:url";

export const RE_ENTRY_ENV = "CONTEXT_CLI_POSTINSTALL";

/**
 * Pure decision function. Given an env snapshot and a "does `context` bin
 * exist on PATH?" predicate result, return either a hint to print or a
 * "do nothing" sentinel.
 *
 * Shape:
 *   { action: "skip", reason: "<why>" }
 *   | { action: "hint", lines: string[] }
 *
 * Every branch that reads process state in the real run is modeled here so
 * tests can pass synthetic envs. The caller decides whether to actually
 * print or set the re-entry guard.
 */
export function decide(env, hasContextBin) {
  if (env[RE_ENTRY_ENV] === "1") {
    return { action: "skip", reason: "re-entry guard" };
  }
  if (env.CI === "true" || env.CI === "1") {
    return { action: "skip", reason: "CI" };
  }
  if (env.npm_config_global === "true") {
    return { action: "skip", reason: "already global install" };
  }
  if (env.CONTEXT_CLI_SKIP_AUTO_LINK === "1") {
    return { action: "skip", reason: "explicit opt-out" };
  }
  if (!looksLikeClaudePluginInstall(env)) {
    return { action: "skip", reason: "not a Claude plugin install" };
  }
  if (hasContextBin) {
    return { action: "skip", reason: "context already on PATH" };
  }
  const version = env.npm_package_version ?? "latest";
  return {
    action: "hint",
    lines: [
      "Claude plugin installed. To enable `/context:*` slash commands, make " +
        "the `context` bin available on PATH by running one of:",
      `  bun install -g @c4a/context-cli@${version}    # install from npm registry`,
      "  bun link                                       # if installing a local build",
    ],
  };
}

/**
 * Heuristic: does the caller's env smell like a Claude plugin install?
 * We require the env var's *value* to contain "claude" (case-insensitive),
 * not just the var to be set — `npm_config_user_agent` is set by every
 * install, and `CLAUDE_PLUGIN_INSTALL=0` should not trigger us either.
 */
export function looksLikeClaudePluginInstall(env) {
  const hints = ["CLAUDE_PLUGIN_INSTALL", "CLAUDE_CODE_PLUGIN", "npm_config_user_agent"];
  for (const key of hints) {
    const value = env[key];
    if (!value) continue;
    if (value.toLowerCase().includes("claude")) return true;
  }
  return false;
}

/**
 * Real-world `hasContextBin` probe: shell out to `command -v` / `where`.
 * Kept separate from `decide()` so tests can inject a synthetic value.
 */
export function probeContextBin() {
  const lookup = process.platform === "win32"
    ? spawnSync("where", ["context"], { stdio: "ignore" })
    : spawnSync("sh", ["-c", "command -v context"], { stdio: "ignore" });
  return lookup.status === 0;
}

function log(msg) {
  process.stderr.write(`[context-cli postinstall] ${msg}\n`);
}

function main() {
  const env = process.env;
  // First pass with hasContextBin=false tells us whether every *other* gate
  // was passed. Only if the decision is "hint" do we need the real PATH
  // probe (avoids shelling out to sh/where in the common skip cases).
  const provisional = decide(env, false);
  if (provisional.action === "skip") return;

  // Decision said "hint" — now probe the real PATH. If context IS on PATH,
  // the user doesn't need the hint.
  const final = decide(env, probeContextBin());
  if (final.action === "hint") {
    for (const line of final.lines) log(line);
  }
  // Set the re-entry guard so any child process we may spawn in the future
  // (we currently don't, but belt-and-suspenders against recursion) will
  // inherit it and skip their own postinstall.
  env[RE_ENTRY_ENV] = "1";
}

// Side-effect wrapper only runs when this file is executed directly as a
// script (the postinstall lifecycle does that). When the file is imported
// by a test, `main()` is not invoked.
//
// IMPORTANT — two quirks that the obvious
//   `import.meta.url === \`file://${process.argv[1]}\``
// check silently fails on, producing an invisible-no-op postinstall:
//
//   1. **Percent-encoding**: `import.meta.url` is always WHATWG-encoded
//      (`%20` for spaces, %-hex for many punctuation chars, uppercase
//      Windows drive letters, UNC `\\?\` prefixes). Raw `argv[1]` isn't.
//      A package install under `/Users/Dev Tools/...` reproducibly no-ops
//      with the interpolated check; going through `pathToFileURL` fixes
//      it.
//   2. **Symlink resolution**: on macOS `/tmp` is a symlink to
//      `/private/tmp`, and `import.meta.url` resolves that; `argv[1]`
//      does not. npm stashes packages under symlinked paths on some
//      setups (npm's cache, nvm prefixes, corporate homedir mounts). We
//      realpath `argv[1]` before URL-encoding so both sides compare the
//      canonical target.
//
// Either quirk alone turns the postinstall script into a silent no-op —
// exactly the invisible-failure mode npm postinstall scripts are
// infamous for. The combined check below is validated by the subprocess
// tests in postinstall.test.ts.
export function isDirectRun(metaUrl, argv1) {
  if (!argv1) return false;
  try {
    let resolved = argv1;
    try {
      resolved = realpathSync(argv1);
    } catch {
      // argv[1] may not exist yet (weird, but possible under npm install
      // lifecycle timing). Fall back to the raw path — pathToFileURL
      // still normalizes encoding, so spaces-but-no-symlinks still works.
    }
    return metaUrl === pathToFileURL(resolved).href;
  } catch {
    return false;
  }
}

if (isDirectRun(import.meta.url, process.argv[1])) {
  try {
    main();
  } catch (err) {
    log(`postinstall error: ${err instanceof Error ? err.message : String(err)}`);
  }
}
