import type { UserRole } from 'officerdb';
import { updateUser } from 'officerdb';
import { ensureOsUser, hardenOwnerHome, osUserHome } from '@@/os-user';
import { provisionSshAccess } from '@@/os-user-ssh';
import { seedShellConfig } from '@@/os-user-shell';
import { provisionClaudeCli } from '@@/os-user-claude';
import { provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
import { provisionRootlessDocker } from '@@/os-user-docker';
import { provisionUserDirs } from '@@/data-path';

// Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys.
//
// Shared by account creation and the retry route, because they are the same work and there are two moments
// it has to happen at — the same reasoning as app-store/members.ts. The list of reasons a retry is needed is
// not exotic:
//
//   - the host was not set up for it when the account was made (no sudoers entry for the service user)
//   - an ancestor directory was not traversable, which is the one everybody hits once
//   - the owner wants to replace the inbound SSH key
//
// Before this existed the answer to all three was "delete the account and make it again", which loses the
// password, the dashboards and anything else keyed to the row — for what is really a retryable side effect.

export type OsProvisionOutcome = {
  osUser: string | null;
  sshPublicKey: string | null;
  /** Null on success. Everything here is non-fatal — the platform account works regardless. */
  error: string | null;
};

/**
 * Idempotent. Every step underneath adopts what already exists: `useradd` is skipped for an account whose
 * home already matches, the confinement re-applies mode bits, and an existing `id_ed25519` is kept rather
 * than rotated (it has been added to Gitea by then).
 *
 * Never throws. A failure here must not undo or block a platform account that otherwise works, so the error
 * is returned and the row keeps `osUser: null` — which is exactly what an account created before this
 * feature looks like, and which every consumer already handles.
 */
export async function provisionOsAccount(params: {
  userId: number;
  email: string;
  username: string;
  /** Decides whether they get a Postgres role. See `rolePermitsDevTools`. */
  role: UserRole;
  /** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
  inboundKey?: string | null;
}): Promise<OsProvisionOutcome> {
  // First, because a missing skeleton is the reason `useradd --home-dir … -M` would have nothing to point at.
  try {
    provisionUserDirs(params.email);
  } catch (ex) {
    return {
      osUser: null,
      sshPublicKey: null,
      error: `could not provision data directories: ${ex instanceof Error ? ex.message : String(ex)}`,
    };
  }

  const account = await ensureOsUser({ email: params.email, username: params.username });
  if (!account.ok) return { osUser: null, sshPublicKey: null, error: account.error };

  // The owner's own dotfiles, closed the moment somebody else can open a shell. Here rather than in setup
  // because ~/.claude and ~/.pm2 are created by USE, not by install — a chmod at install time finds them
  // missing and silently does nothing. Deliberately after `ensureOsUser`: before it there is no account to
  // hide anything from, and the log line is more useful when it names one.
  const hardened = await hardenOwnerHome();
  if (hardened.closed.length > 0) {
    console.log(`[provision] closed the owner's ${hardened.closed.join(', ')} to ${account.osUser}`);
  }
  if (!hardened.ok) console.warn(`[provision] could not close the owner's home: ${hardened.error}`);

  // SSH after the account, because everything it writes lives inside a home that is not ours until
  // `ensureOsUser` has chowned it away.
  const ssh = await provisionSshAccess({
    email: params.email,
    osUser: account.osUser,
    uid: account.uid,
    gid: account.gid,
    authorizedKey: params.inboundKey,
  });

  // The shell configuration. Late because its failure leaves nothing broken — the account works, the keys
  // work, the terminal opens; it just opens with zsh's bare defaults.
  const shell = await seedShellConfig({ email: params.email, uid: account.uid, gid: account.gid });

  // Their own `claude`, in their own home. After the shell because it installs into a home that is not ours
  // until `ensureOsUser` has chowned it away, and because the installer wants a working HOME.
  //
  // Only the binary. Logging in is the member's own act against their own Anthropic account — the platform
  // cannot do it for them and must not try, because the alternative is lending them the owner's credential.
  const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser });

  // A Postgres login role of the same name, with CREATEDB — for a Developer, and nobody else. What
  // replaced rootless Docker for the "let me run a database to develop against" case, and it inherits
  // that feature's role gate along with its purpose: `rolePermitsDevTools` is the one place that rule is
  // written down.
  //
  // Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function
  // that creates the role rather than in the setup script, so it cannot be skipped by an install that was
  // set up before this existed. See os-user-postgres.ts.
  //
  // Null, not a skipped-but-ok result: "there is no database role because of who they are" and "the
  // database role worked" are different answers and the caller reports them differently.
  const postgres = rolePermitsDevTools(params.role)
    ? await provisionPostgresRole({
        email: params.email,
        osUser: account.osUser,
        uid: account.uid,
        gid: account.gid,
      })
    : null;

  // Their own rootless Docker daemon — for a Developer, and nobody else. Last, and the most tolerant of
  // failure: a host without the uidmap package or a kernel that will not do rootless still gets a perfectly
  // good account, minus containers.
  //
  // Behind the role gate rather than unconditional. The cost is real and it is per member, not per install:
  // one daemon, one image cache and one subuid range each — see os-user-docker.ts. That is worth paying for
  // someone whose job is to build things here, and not for an account that just reads its dashboards.
  const docker = rolePermitsDevTools(params.role)
    ? await provisionRootlessDocker({
        osUser: account.osUser,
        uid: account.uid,
        gid: account.gid,
        home: osUserHome(params.email),
      })
    : null;

  // The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as
  // it. Only the keys are missing, and that is what the error says.
  const sshPublicKey = ssh.ok ? ssh.publicKey : null;
  await updateUser(params.userId, { osUser: account.osUser, osSshPublicKey: sshPublicKey });

  // Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt,
  // which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are
  // in the log.
  for (const step of [claude, shell, postgres, docker]) {
    if (step && !step.ok) console.warn(`[users] ${params.email}: ${step.error}`);
  }
  const error = !ssh.ok
    ? ssh.error
    : !claude.ok
      ? claude.error
      : !shell.ok
        ? shell.error
        : postgres && !postgres.ok
          ? postgres.error
          : docker && !docker.ok
            ? docker.error
            : null;
  return { osUser: account.osUser, sshPublicKey, error };
}
