import { join } from 'node:path';
import { osUserHome, runAs } from './os-user';

// Claude, per member: their own binary, their own login, in their own home.
//
// ── Why not one shared binary ──
//
// A single `/usr/local/bin/claude` would be less disk and one version to reason about, and the argument for
// it is real: the private part of Claude is the credential in `~/.claude`, not the executable. It is still
// the wrong shape here. `claude` updates itself — that is why the owner's own install goes through
// Anthropic's installer rather than npm (`scripts/setup/setup.sh:861`) — and a root-owned binary is one a member
// cannot update, which turns "my agent is a version behind" into a request to the owner. Per-member also
// means the account's agent keeps working exactly as the tool ships, with no platform-shaped exception to
// explain. Same command the owner ran, run as them, in their home.
//
// ── The credential is theirs, and this is what makes that true ──
//
// A member's `claude` must never see the owner's Anthropic proxy. `sidecar/claude/user-instance.ts:148`
// sets `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY` and `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL` on the agent
// sidecar's own `process.env`, so anything spawned from that process inherits the owner's credential by
// default — the leak would be the absence of an action, not an action. `runAs` closes it structurally:
// `--reset-env` clears the environment on the way through `setpriv`, so a variable reaches a member only
// because someone wrote it into the command (`os-user.ts:120`). Default deny, and nothing to remember.
//
// ── Login is the member's own, and cannot be done for them ──
//
// `claude` authenticates interactively against an account. The platform therefore cannot log a member in,
// and should not want to: their subscription is theirs. All this file can do is install the binary and
// report whether the credential has appeared, so the UI can render the one-line instruction instead of an
// agent that fails for reasons nobody can see.

/** Anthropic's own installer — the same one `scripts/setup/setup.sh` uses for the owner, chosen for auto-update. */
const CLAUDE_INSTALL_URL = 'https://claude.ai/install.sh';

/**
 * Where the installer puts it, given a home. Also the first path `claude-manager.ts` probes after
 * `$CLAUDE_BIN`.
 *
 * Takes a home rather than an email because the spawn side only ever has the home — it comes from
 * `resolveHomeDir`, not from a lookup. One derivation for both sides: installing to one path and exec'ing
 * another is the kind of divergence that surfaces as "the agent works for some members".
 */
export const claudeBinIn = (home: string): string => join(home, '.local', 'bin', 'claude');

/** The same path, for callers that hold an email. */
export const claudeBinPath = (email: string): string => claudeBinIn(osUserHome(email));

/**
 * The file whose existence means "this account has logged in".
 *
 * `~/.claude.json` is not the marker — it holds settings and history and appears on first run, logged in or
 * not. `~/.credentials.json` is written by a completed login and is mode 600, which is also why this is
 * checked by running as the member rather than by reading it: we need to know the credential is there, never
 * what is in it.
 */
const credentialsPath = (email: string): string => join(osUserHome(email), '.claude', '.credentials.json');

/**
 * Run a command as the member.
 *
 * `out` merges stdout and stderr and exists for logging — a failure is diagnosable only if both are in it.
 * `stdout` is kept separate for anything that *decides* on output, because merging the two channels means the
 * decision can be moved by anything that writes to stderr: a shell trace, a sudo banner, a wrapper echoing
 * argv. Never match on `out`.
 */
async function asMember(osUser: string, command: string[]): Promise<{ ok: boolean; out: string; stdout: string }> {
  const proc = runAs(osUser, command);
  const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
  return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim(), stdout: out };
}

/**
 * Read the two-character probe below: position 0 is the binary, position 1 is the credential.
 *
 * Split out as a pure function so the parsing is testable without a subprocess, and — more to the point —
 * so it can only ever see stdout. The previous version decided with `out.includes('bin')` and
 * `out.includes('cred')` against the merged channel, and both markers are substrings of the paths being
 * tested: `bin` ⊂ `…/.local/bin/claude`, `cred` ⊂ `…/.claude/.credentials.json`. One `set -x` and the trace
 * of the test command itself set both flags true with neither file present — verified on the live server
 * against an account that had never logged in.
 *
 * No marker spelling fixes that, because a trace echoes the literal along with the path. The channel was the
 * bug, so the fix is the channel plus reading by position rather than by substring.
 */
export function parseLoginProbe(stdout: string): ClaudeLoginState {
  return { installed: stdout[0] === '1', loggedIn: stdout[1] === '1' };
}

export type ClaudeProvisionResult =
  /** `wrote` is false when the binary was already there — a reprovision must not re-download. */
  { ok: true; binPath: string; wrote: boolean } | { ok: false; error: string };

/**
 * Install `claude` into the member's home, as the member.
 *
 * Idempotent by skipping outright when the binary is present, rather than by re-running the installer: the
 * retry button reprovisions an account whenever the owner presses it, and re-downloading would both cost a
 * network round trip per press and quietly move a member off the version they had chosen by updating.
 *
 * Never throws. An account with no agent is still a working account — the same posture as SSH keys and
 * rootless Docker in `provisionOsAccount`.
 */
export async function provisionClaudeCli(params: { email: string; osUser: string }): Promise<ClaudeProvisionResult> {
  const binPath = claudeBinPath(params.email);

  const present = await asMember(params.osUser, ['test', '-x', binPath]);
  if (present.ok) return { ok: true, binPath, wrote: false };

  // Piped into `bash`, not `sh`. A script read on stdin never has its shebang honoured — the interpreter you
  // name is the one that runs it — and `install.sh` declares `#!/bin/bash` and uses `[[ … =~ … ]]` on line 9.
  // On Ubuntu `/bin/sh` is dash, so `| sh` died with `Syntax error: "(" unexpected`, which reads like a broken
  // download rather than the wrong interpreter. Reproduced on the live server: `dash -n` fails there, `bash -n`
  // is clean.
  const install = await asMember(params.osUser, ['sh', '-c', `set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash`]);

  // The installer's exit code is not the gate — the same lesson as rootless Docker in
  // `docs/per-user-linux-accounts.md`. What matters is whether the binary is now there and runnable.
  const installed = await asMember(params.osUser, ['test', '-x', binPath]);
  if (!installed.ok) {
    return { ok: false, error: `claude did not install for ${params.osUser}: ${install.out || 'no output'}` };
  }

  return { ok: true, binPath, wrote: true };
}

export type ClaudeLoginState = {
  /** The binary is present and executable in their home. */
  installed: boolean;
  /** A completed login has written credentials. False means the member has to run `claude` once themselves. */
  loggedIn: boolean;
};

/**
 * Whether this account can actually run an agent turn.
 *
 * Both halves are read as the member, so a `true` here means the member's own process can reach these files
 * — which is the thing the answer is used to promise. Checking as root would confirm the file exists while
 * saying nothing about whether the account that needs it can see it.
 */
export async function claudeLoginState(params: { email: string; osUser: string }): Promise<ClaudeLoginState> {
  // One `runAs` for both answers, not two. Each is a `sudo -n setpriv` fork/exec that writes a line to
  // `/var/log/auth.log`, and this is reached from `/agent-status`, which sits on a grant every role has by
  // default — so a UI that polls it would otherwise cost two sudo spawns and two auth-log lines per poll, per
  // member. Individually cheap, unbounded in aggregate, and the auth log is where a real sudo event has to
  // stay visible.
  //
  // Two characters on stdout, read by position — see `parseLoginProbe` for why not markers. `-x` follows
  // symlinks, which is what the installer produces: a link into a versioned directory, not a file.
  const probe = await asMember(params.osUser, [
    'sh',
    '-c',
    'if test -x "$1"; then printf 1; else printf 0; fi; if test -s "$2"; then printf 1; else printf 0; fi',
    '_',
    claudeBinPath(params.email),
    credentialsPath(params.email),
  ]);
  return parseLoginProbe(probe.stdout);
}
