import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { osUserHome, runAs } from './os-user';

// SSH for a member's Linux account: how they reach the machine, and how the machine reaches Gitea as them.
//
// ── Two keys, two directions, and they are not alternatives ──
//
//   inbound   `authorized_keys` holds a public key the OWNER pasted at create time. Their private half
//             stays on their laptop. Optional: an account with none is platform-only, which is a fine
//             state, just not a shell-from-anywhere one.
//   outbound  `id_ed25519` is generated HERE, in their home, and never leaves. This is what pushes to
//             Gitea.
//
// The distinction matters because "he pasted his key, so we can skip generating one" is the obvious
// simplification and it breaks the actual goal. Agent forwarding covers a human in an interactive SSH
// session; a platform-spawned agent has no agent socket to borrow, so an edge checkout it is asked to
// commit and push needs a key that lives on the box.
//
// ── Why every write goes through `sudo install` ──
//
// The home is 700 and owned by the member, so the service user cannot write into it at all — not even to
// create `.ssh`. `install` sets content, owner and mode in ONE step, which also closes the window where a
// key file exists at the process umask before a chmod lands. And passing file content as a path rather
// than as shell text means nothing here has to reason about quoting a value that came from a form.

/**
 * Public key formats OpenSSH accepts, anchored and single-line.
 *
 * Validated because this string is appended to `authorized_keys`, where each line is a credential. A
 * value with an embedded newline would inject a SECOND authorized key — so the check that matters is not
 * "does this look like a key" but "is this exactly one line".
 */
const PUBLIC_KEY_RE =
  /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/]+={0,3}(\s+\S.*)?$/;

export function validatePublicKey(raw: string): { ok: true; key: string } | { ok: false; error: string } {
  const key = raw.trim();
  if (!key) return { ok: false, error: 'empty' };
  // Checked before the pattern so the message is about the real problem: a pasted `id_ed25519` (private)
  // or a multi-key blob are both things people actually do.
  if (/[\r\n]/.test(key)) return { ok: false, error: 'a public key must be a single line' };
  if (key.includes('PRIVATE KEY')) return { ok: false, error: 'that is a PRIVATE key — paste the .pub file' };
  if (!PUBLIC_KEY_RE.test(key)) {
    return { ok: false, error: 'not an OpenSSH public key (expected e.g. "ssh-ed25519 AAAA… comment")' };
  }
  return { ok: true, key };
}

type SudoResult = { ok: boolean; out: string };

async function sudo(args: string[]): Promise<SudoResult> {
  const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
  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() };
}

/** Write `content` into the member's tree with the right owner and mode, via a temp file. */
async function installFile(params: {
  content: string;
  dest: string;
  uid: number;
  gid: number;
  mode: string;
}): Promise<SudoResult> {
  const dir = await mkdtemp(join(tmpdir(), 'officer-ssh-'));
  const staged = join(dir, 'staged');
  try {
    await writeFile(staged, params.content, { mode: 0o600 });
    return await sudo([
      'install',
      '-o',
      String(params.uid),
      '-g',
      String(params.gid),
      '-m',
      params.mode,
      staged,
      params.dest,
    ]);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
}

export type SshProvisionResult =
  | { ok: true; publicKey: string; generated: boolean; inboundKeyInstalled: boolean }
  | { ok: false; error: string };

/**
 * Give the account a working `~/.ssh`: inbound key if one was supplied, and an outbound keypair either way.
 *
 * Idempotent. An existing `id_ed25519` is kept and its public half returned rather than regenerated —
 * rotating a key silently would break every Gitea account and deploy key it had been added to.
 */
export async function provisionSshAccess(params: {
  email: string;
  osUser: string;
  uid: number;
  gid: number;
  /** The owner-supplied public key for inbound SSH. Absent or empty means no inbound access. */
  authorizedKey?: string | null;
}): Promise<SshProvisionResult> {
  const home = osUserHome(params.email);
  const sshDir = join(home, '.ssh');
  const keyPath = join(sshDir, 'id_ed25519');

  // `install -d` creates the directory with the owner and mode in one call. sshd refuses to use a .ssh
  // that is group- or world-writable, so 700 is a requirement rather than caution.
  const dir = await sudo(['install', '-d', '-o', String(params.uid), '-g', String(params.gid), '-m', '700', sshDir]);
  if (!dir.ok) return { ok: false, error: `could not create ${sshDir}: ${dir.out}` };

  let inboundKeyInstalled = false;
  if (params.authorizedKey?.trim()) {
    const checked = validatePublicKey(params.authorizedKey);
    if (!checked.ok) return { ok: false, error: `public key rejected: ${checked.error}` };
    const written = await installFile({
      content: `${checked.key}\n`,
      dest: join(sshDir, 'authorized_keys'),
      uid: params.uid,
      gid: params.gid,
      mode: '600',
    });
    if (!written.ok) return { ok: false, error: `could not write authorized_keys: ${written.out}` };
    inboundKeyInstalled = true;
  }

  // `accept-new` rather than seeding known_hosts with ssh-keyscan. We do not know the Gitea SSH host at
  // account-creation time — the platform stores an HTTP base URL, and the SSH endpoint may be a different
  // host or port entirely. The failure this prevents is specific and nasty: default StrictHostKeyChecking
  // makes a first connection PROMPT, and a prompt in a non-interactive agent turn is a hang, not an error.
  // `accept-new` trusts on first use and still refuses a CHANGED key, which is the attack that matters.
  const config = await installFile({
    content: ['Host *', '  StrictHostKeyChecking accept-new', '  IdentityFile ~/.ssh/id_ed25519', ''].join('\n'),
    dest: join(sshDir, 'config'),
    uid: params.uid,
    gid: params.gid,
    mode: '600',
  });
  if (!config.ok) return { ok: false, error: `could not write ssh config: ${config.out}` };

  // Checked with sudo: the service user cannot stat inside a 700 home. It matters that this is checked
  // rather than attempted — `ssh-keygen` on an existing path PROMPTS to overwrite, and that prompt in a
  // spawned process is a hang.
  const exists = await sudo(['test', '-f', keyPath]);
  let generated = false;
  if (!exists.ok) {
    // Generated AS the member so the files are theirs from the moment they exist; a private key that is
    // briefly root-owned is a private key that can be left root-owned by a failure halfway through.
    const proc = runAs(params.osUser, [
      'ssh-keygen',
      '-t',
      'ed25519',
      '-N',
      '',
      '-C',
      `${params.osUser}@officer`,
      '-f',
      keyPath,
    ]);
    const err = await new Response(proc.stderr).text();
    if ((await proc.exited) !== 0) return { ok: false, error: `ssh-keygen failed: ${err.trim()}` };
    generated = true;
  }

  const pub = await sudo(['cat', `${keyPath}.pub`]);
  if (!pub.ok) return { ok: false, error: `could not read the generated public key: ${pub.out}` };

  return { ok: true, publicKey: pub.out.trim(), generated, inboundKeyInstalled };
}
