import type { SpawnOptions, SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
import { spawn } from 'node:child_process';
import { join, resolve } from 'node:path';
import { runAsArgv } from '@@/os-user';
import { claudeBinIn } from '@@/os-user-claude';

// Running a member's agent turn as the member, without a second sidecar.
//
// ── Why the sidecar stays as the service user and only the CLI drops privileges ──
//
// The obvious reading of "each member runs their own Claude" is a second `officer-agent` running under their
// uid. That does not work, and the reason is worth writing down because it looks like an implementation
// detail and is actually a boundary. This sidecar needs `POSTGRES_URL` (it imports `officerdb`) and the JWT
// signing secret — it mints a 30-day owner token for the MCP tools. A process running as a member with those
// two values in its environment can read every account's data and sign a token as the owner, which is
// strictly more than a member's shell can do. `docs/per-user-linux-accounts.md` already forbids exactly this:
// `.env` is 600 and a boot check refuses to start with OS users enabled while it is readable.
//
// So the split is: the platform glue stays the service user's, and the thing that runs the member's code and
// holds the member's credential — `claude` itself — is theirs. The member's agent process genuinely is their
// own, in their home, with their login and their binary. Only the harness around it is shared, and the
// harness is the part that must not be.
//
// This is the pty sidecar's shape, which is the established one here: one process, per-request identity,
// privileges dropped at the point where the member's code starts (`sidecar/pty/sessions.mjs:85-93`).
//
// ── Why an allowlist and not a filter ──
//
// `runAsArgv` uses `setpriv --reset-env`, so nothing crosses into the member's process unless it is written
// into the argv (`os-user.ts:120-124`). That is the whole safety property here, and it points one way: build
// the child's environment from an allowlist rather than by subtracting the dangerous names from this
// process's. A denylist has to be right about every variable that exists now and every one added later —
// including `POSTGRES_URL`, the JWT secret, and the owner's `ANTHROPIC_API_KEY`, all of which are sitting in
// this process's environment as it makes this call. An allowlist is wrong in the direction that fails safe.

/** Everything needed to run a turn as one member. Resolved by the platform, never taken from a client. */
export type MemberRun = {
  /** Their Linux account, from `users.osUser`. */
  osUser: string;
  /** Their home — `DATA_PATH/<email>/home`, per `resolveHomeDir`. */
  home: string;
};

/**
 * The variables a member's `claude` is allowed to inherit.
 *
 * Deliberately short. `setpriv --init-groups --reset-env` already supplies HOME, USER, LOGNAME, SHELL and
 * PATH from their passwd entry, so this list is only what the harness itself needs on top of that.
 *
 * **Nothing here may ever be a secret.** These are passed as `env K=V …` inside the argv, which means every
 * one of them is visible in `/proc/<pid>/cmdline` to every account on the box. That is fine for locale and
 * terminal settings and is not fine for a token — a credential belongs in the member's own `~/.claude`, which
 * is theirs and mode 600, not on a command line.
 */
const ALLOWED_ENV = ['LANG', 'LC_ALL', 'TERM', 'TZ', 'NO_COLOR', 'CLAUDE_CODE_ENTRYPOINT'] as const;

/** Set by `memberEnv` itself rather than inherited, so vetted the same way. */
const ALSO_ALLOWED = ['HOME', 'CLAUDE_CONFIG_DIR'] as const;

/**
 * Credential names that must never appear in the allowlist. **This guards the constant, not the instance.**
 *
 * Two dead checks were written here before this one worked, and the distinction is the whole lesson. A
 * denylist tested against `childEnv` cannot fire, because `memberEnv` builds that object *from* `ALLOWED_ENV`.
 * Inverting it to a subset test cannot fire either, for the same reason — and it is strictly worse, because
 * widening `ALLOWED_ENV` widens the permitted set in the same motion, so the one realistic dangerous edit
 * (somebody adds a credential to the allowlist) stops throwing and starts passing silently.
 *
 * Tested against the *list*, it fires on exactly that edit. Being incomplete is then survivable: a name this
 * misses degrades to the status quo rather than to false confidence. The Anthropic and Claude entries are what
 * the installed SDK reads; the last two are what make this process more privileged than a member's shell.
 */
const NEVER_ENV = [
  'ANTHROPIC_BASE_URL',
  'ANTHROPIC_API_KEY',
  'ANTHROPIC_AUTH_TOKEN',
  'ANTHROPIC_FOUNDRY_API_KEY',
  '_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
  'CLAUDE_CODE_OAUTH_TOKEN',
  'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
  'CLAUDE_CODE_SESSION_ACCESS_TOKEN',
  'CLAUDE_CODE_CLIENT_KEY',
  'CLAUDE_API_KEY',
  'POSTGRES_URL',
  'JWT_SECRET',
];

/**
 * The `claude` config directory for a member.
 *
 * `--reset-env` already sets HOME to their home, so `~/.claude` would resolve correctly on its own. This is
 * set explicitly anyway because "which account's credential did this turn use" is the single most important
 * question in this file, and it should be answerable by reading one line rather than by reasoning about what
 * `setpriv` does to HOME.
 */
export const memberClaudeConfigDir = (home: string): string => join(home, '.claude');

/** Everything a member's turn may carry. Exported so a test can assert the shipping lists are clean. */
export const PERMITTED_ENV: ReadonlySet<string> = new Set<string>([...ALLOWED_ENV, ...ALSO_ALLOWED]);

/**
 * The two env guards, as one pure function so they can be tested.
 *
 * They were unreachable from a test while they lived inside the spawn closure — the only way to exercise them
 * was to spawn, and the passing path spawns `sudo`. Which is how both earlier versions of this guard shipped
 * dead: nothing could demonstrate them firing.
 *
 * @param permitted the names allowed for this turn — parameterised so a test can pass a poisoned list
 * @param childEnv  what `memberEnv` actually produced
 */
export function assertEnvSafe(permitted: ReadonlySet<string>, childEnv: Record<string, string>, who: string): void {
  // Guards the constant: a credential added to the allowlist throws rather than reaching a member.
  const leaked = NEVER_ENV.filter((name) => permitted.has(name));
  if (leaked.length) throw new Error(`refusing to run ${who}'s agent: ${leaked.join(', ')} is in the allowlist`);

  // Guards the construction: a key `memberEnv` invents that nobody vetted.
  const unexpected = Object.keys(childEnv).filter((name) => !permitted.has(name));
  if (unexpected.length) throw new Error(`refusing to run ${who}'s agent with unvetted env: ${unexpected.join(', ')}`);
}

/** Build the child environment for a member's turn: allowlist in, everything else absent. */
function memberEnv(run: MemberRun, inherited: Record<string, string | undefined>): Record<string, string> {
  const env: Record<string, string> = {};
  for (const name of ALLOWED_ENV) {
    const value = inherited[name];
    if (value !== undefined) env[name] = value;
  }
  env.HOME = run.home;
  env.CLAUDE_CONFIG_DIR = memberClaudeConfigDir(run.home);
  return env;
}

/**
 * The SDK's spawn hook, bound to one member.
 *
 * `spawnClaudeCodeProcess` (`sdk.d.ts:951`, "use this to run Claude Code in VMs, containers, or remote
 * environments") is what makes this possible at all. The plan of record assumed the SDK had nowhere to put a
 * uid and that a member's turn therefore had to become its own process — a change of shape rather than a
 * flag. It is a flag.
 *
 * `node:child_process` rather than `Bun.spawn`, for two reasons: its return value already satisfies
 * `SpawnedProcess` (a Writable stdin, a Readable stdout, `kill`, `on('exit')`), which Bun's does not — Bun
 * gives web streams and no emitter — and `Bun.spawn` silently ignores `uid`/`gid` anyway, which is why
 * `runAs` exists and why `os-user.test.ts` pins that behaviour.
 */
export function spawnClaudeAsMember(run: MemberRun): (options: SpawnOptions) => SpawnedProcess {
  return ({ command, args, cwd, env, signal }: SpawnOptions): SpawnedProcess => {
    const childEnv = memberEnv(run, env);

    assertEnvSafe(PERMITTED_ENV, childEnv, run.osUser);

    // The binary must be theirs — established without touching their filesystem.
    //
    // Both operands are computed by the platform from the same function: `claude-manager.ts` sets
    // `pathToClaudeCodeExecutable: claudeBinIn(member.home)`, and this recomputes it. So string equality
    // establishes exactly what the check is for, and if the SDK ever normalises the path it fails closed and
    // loudly rather than silently.
    //
    // This was `realpathSync` on both sides for one commit, to survive an upstream that resolves symlinks —
    // and there is no such upstream, because the platform controls both ends. The cost was total: a member's
    // home is 700, the platform is `other`, so `realpathSync` threw EACCES, the catch turned that into
    // "not their binary", and EVERY member turn would have been refused forever the moment the gates moved.
    // Verified on the production host against a byte-identical path. Failing closed was the right direction
    // and it made the feature impossible rather than unsafe.
    //
    // The alternative — granting the service user traverse — needs `x` on `.local`, `.local/share`,
    // `.local/share/claude` and `versions/`, which reopens a decision made deliberately in `16` across a whole
    // subtree rather than one directory.
    const expectedBin = claudeBinIn(run.home);
    if (resolve(command) !== expectedBin) {
      throw new Error(`refusing to run ${command} as ${run.osUser}; expected their own ${expectedBin}`);
    }

    // `env K=V …` inside the argv, because `--reset-env` clears anything handed to `setpriv` itself. This is
    // the only channel through which a variable can reach the member's process.
    const assignments = Object.entries(childEnv).map(([name, value]) => `${name}=${value}`);
    const [launcher, ...launcherArgs] = runAsArgv(run.osUser, ['env', ...assignments, command, ...args]);
    // Unreachable — `runAsArgv` always returns `sudo` first and throws on an empty command. Written rather
    // than asserted away because the alternative under `noUncheckedIndexedAccess` is a default that would
    // silently pick a launcher, and a wrong launcher here means a turn running as the wrong user.
    if (!launcher) throw new Error('runAsArgv returned an empty argv');

    const child = spawn(launcher, launcherArgs, {
      // Their home, not this process's. A cwd outside it would be readable by the turn only if the member
      // could read it anyway — the kernel is the check here, not this line — but defaulting to their home is
      // what makes an unqualified turn behave like their shell.
      cwd: cwd ?? run.home,
      stdio: ['pipe', 'pipe', 'pipe'],
      signal,
    });

    // Non-null by construction: 'pipe' on all three above. The SDK's interface wants them non-nullable and
    // node types them as possibly-null because other stdio modes exist.
    return child as unknown as SpawnedProcess;
  };
}
