import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { UserRole } from 'officerdb';
import { sql } from 'drizzle-orm';
import postgres from 'postgres';
import { db } from 'officerdb/db';
import { osUserHome, runAs } from './os-user';

// A Postgres login role for a member, named the same as their Linux account.
//
// This is what replaced rootless Docker per member (disabled 2026-08-13). The use case was always "let me
// run a database to develop against" and a container per member was an expensive way to answer it — one
// daemon, one image cache and one subuid range each. A role on the cluster that is already running costs
// a row in `pg_authid`.
//
// ── What the member gets ──
//
//   CREATEDB      as many databases as they like, owned by them. Not one database provisioned for them:
//                 the ask was to create databases in their own name, not to be handed one.
//   ~/.zshenv     PGHOST/PGPORT/PGUSER/PGPASSWORD, so every pg command works with no flags — and an
//                 obvious place to read your own password back from.
//   ~/.pgpass     the same credential in the form libpq reads by itself, for everything that is not a
//                 zsh process.
//
// Both 600, owned by them, written the same way as their SSH keys — see os-user-ssh.ts for why every
// write into a 700 home goes through `sudo install`.
//
// ── What keeps them out of everything else ──
//
// Measured on postgres:18-alpine, 2026-08-13, because every part of this was wrong when assumed:
//
//   - A fresh `CREATE ROLE … LOGIN` CAN connect to `officer`. `pg_database.datacl` is NULL, which means
//     default privileges, which for a database means PUBLIC holds CONNECT and TEMPORARY. That is why
//     `ensureAppDatabaseClosed` exists and why it runs before any role is created.
//   - Once connected it can NOT read an application table. Table privileges default to owner-only and
//     nothing grants to PUBLIC — `has_table_privilege('users','UPDATE')` is false. So the capability model
//     was never reachable from here; the exposure was catalogue metadata, not data.
//   - Revoking from the ROLE does not help. Postgres privileges are additive and there is no DENY, so a
//     PUBLIC grant is not overridden by a role-level revoke. Revoking from PUBLIC is the only lock.
//
// ── Keeping members out of EACH OTHER'S databases ──
//
// Harder than it sounds, and the two obvious routes are both dead ends. `datacl` is NOT inherited from the
// template — measured: closing `template1` and creating a database from it still produced a NULL `datacl`,
// so PUBLIC gets CONNECT on every database a member makes. And `CREATE DATABASE` fires no event trigger,
// because it is a global object, so nothing can react at the moment of creation.
//
// What does work is a `login` event trigger (PG17+) installed in `template1`. Event triggers live in a
// per-database catalogue, and `CREATE DATABASE` copies the template's catalogues — so every database a
// member creates carries it, automatically, with no naming convention, no sweep and no window.
//
// ── Why the function lives in its own schema ──
//
// The first version put it in `public` and a member DEFEATED IT IN ONE STATEMENT:
//
//   DROP FUNCTION public.officer_owner_only() CASCADE;   -- takes the event trigger with it
//
// They could not drop or disable the trigger — `must be owner of event trigger` — but in PG15+ the
// `public` schema is owned by `pg_database_owner`, which resolves to THEM inside their own database, and a
// schema owner may drop objects in it that they do not own. Both objects were owned by `postgres` and it
// made no difference.
//
// So the function goes in a schema owned by the platform with PUBLIC revoked. Measured after the move —
// every route refused: DROP EVENT TRIGGER, ALTER … DISABLE, DROP FUNCTION, DROP SCHEMA, ALTER SCHEMA …
// OWNER TO, CREATE OR REPLACE over the top, and `PGOPTIONS=-c event_triggers=off` (that GUC is superuser
// only). A superuser can still use it, which is the recovery path if this ever locks something out.

/**
 * Which platform roles get a database role at all.
 *
 * `Developer` only. This is the gate rootless Docker was under before it was disabled, inherited by the
 * thing that replaced it rather than invented for it — a member who was not trusted to run containers is
 * not thereby trusted to run databases.
 *
 * `Admin` is deliberately NOT included. Administering the platform is not developing on it, and the two
 * are separate roles precisely so they can be held separately. Add it here if that turns out to be wrong;
 * do not add it at a call site.
 *
 * The owner never reaches this — they run as the service account and already have the cluster.
 */
export const rolePermitsDevTools = (role: UserRole): boolean => role === 'Developer';

/** Legal Linux/Postgres account name. Identical to `validateUsername`, restated because this one reaches SQL. */
const ROLE_NAME_RE = /^[a-zA-Z0-9._-]{2,32}$/;

// Deliberately alphanumeric. This value is interpolated into `PASSWORD '…'` as a literal, because
// CREATE ROLE is a utility statement and cannot take a bind parameter — so the safety has to come from
// the alphabet rather than from escaping. No quote, no backslash, nothing to terminate the literal with.
const PASSWORD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const PASSWORD_LENGTH = 40;

// Every attribute stated, including the negatives, and shared by CREATE and ALTER so the two cannot drift.
// The defaults are already NO* for all of them — writing them down is what makes a hand-made change to a
// member's role show up as a difference rather than as the way it always was.
const ROLE_ATTRIBUTES = 'LOGIN CREATEDB NOSUPERUSER NOCREATEROLE NOREPLICATION NOBYPASSRLS';

/** Uniform over the alphabet by rejection sampling — `% n` on a byte would bias the first 4 characters. */
export function generatePassword(length = PASSWORD_LENGTH): string {
  const max = 256 - (256 % PASSWORD_ALPHABET.length);
  let out = '';
  while (out.length < length) {
    const bytes = new Uint8Array(length);
    crypto.getRandomValues(bytes);
    for (const byte of bytes) {
      if (byte >= max) continue;
      out += PASSWORD_ALPHABET[byte % PASSWORD_ALPHABET.length];
      if (out.length === length) break;
    }
  }
  return out;
}

/**
 * Where `.pgpass` should point, taken from the platform's own connection string.
 *
 * The member connects to the same cluster the platform does, so deriving it means one fact rather than two
 * that can disagree. Postgres is published on loopback only, which is why 127.0.0.1 is the fallback rather
 * than a guess.
 */
export function pgpassTarget(postgresUrl: string | undefined): { host: string; port: string } {
  if (!postgresUrl) return { host: '127.0.0.1', port: '5432' };
  try {
    const url = new URL(postgresUrl);
    return { host: url.hostname || '127.0.0.1', port: url.port || '5432' };
  } catch {
    return { host: '127.0.0.1', port: '5432' };
  }
}

async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> {
  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() };
}

/** Same shape as os-user-ssh's: content, owner and mode in one step, so the file never exists at the umask. */
async function installFile(params: { content: string; dest: string; uid: number; gid: number; mode: string }) {
  const dir = await mkdtemp(join(tmpdir(), 'officer-pg-'));
  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 });
  }
}

/**
 * Shut PUBLIC out of the platform's own database.
 *
 * Runs here, before any role is created, rather than once in the setup script. The guarantee then lives
 * with the code that creates the hazard: an install whose setup predates this, or was run by hand, or was
 * restored from a dump, still cannot end up with a member who can connect to `officer`.
 *
 * Idempotent — REVOKE on a privilege that is already absent succeeds and changes nothing.
 *
 * The platform is unaffected: it connects as a superuser, which bypasses privilege checks entirely. That
 * is also the reason this is safe to run against a live server.
 */
export type AppDatabaseClosedResult = { ok: true; database: string } | { ok: false; error: string };

export async function ensureAppDatabaseClosed(): Promise<AppDatabaseClosedResult> {
  try {
    const [row] = await db.execute<{ current_database: string }>(sql`select current_database()`);
    const database = row?.current_database;
    if (!database) return { ok: false, error: 'could not determine the current database' };

    // TEMPORARY as well as CONNECT. CONNECT is the lock; TEMPORARY is the only other thing PUBLIC holds by
    // default, and leaving it grants a connected superuser-adjacent role nothing but a way to fill a disk.
    await db.execute(sql`REVOKE CONNECT, TEMPORARY ON DATABASE ${sql.identifier(database)} FROM PUBLIC`);
    return { ok: true, database };
  } catch (ex) {
    return { ok: false, error: `could not close the platform database to PUBLIC: ${asMessage(ex)}` };
  }
}

const asMessage = (ex: unknown): string => (ex instanceof Error ? ex.message : String(ex));

/** The isolation schema, function and login trigger. Ordered; each is idempotent on its own. */
const TEMPLATE_ISOLATION_SQL = [
  `CREATE SCHEMA IF NOT EXISTS officer AUTHORIZATION CURRENT_USER`,
  `REVOKE ALL ON SCHEMA officer FROM PUBLIC`,
  // SECURITY INVOKER (the default) with a pinned search_path. DEFINER is not needed — every catalogue this
  // reads is world-readable — and not using it removes the search_path-shadowing class of problem outright.
  `CREATE OR REPLACE FUNCTION officer.owner_only() RETURNS event_trigger
     LANGUAGE plpgsql SET search_path = pg_catalog AS $fn$
   BEGIN
     IF (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) THEN RETURN; END IF;
     IF pg_has_role(current_user, (SELECT datdba FROM pg_database WHERE datname = current_database()), 'USAGE')
       THEN RETURN; END IF;
     RAISE EXCEPTION 'permission denied for database "%"', current_database()
       USING HINT = 'Officer: a database is private to the account that created it.';
   END $fn$`,
  // No IF NOT EXISTS for event triggers, so drop and recreate. The gap is inside template1 and only matters
  // to a CREATE DATABASE landing in the same instant, which cannot happen — see the connection note below.
  `DROP EVENT TRIGGER IF EXISTS officer_owner_only_login`,
  `CREATE EVENT TRIGGER officer_owner_only_login ON login EXECUTE FUNCTION officer.owner_only()`,
];

/**
 * Install the isolation trigger into `template1`, so every database created afterwards inherits it.
 *
 * ── Why this opens its own connection, and closes it immediately ──
 *
 * The platform's pool is connected to `officer`; a connection cannot change database. And `CREATE DATABASE`
 * requires that NO other session is connected to the template it copies — so a pooled or long-lived
 * connection to `template1` would make every member's `createdb` fail with "source database is being
 * accessed by other users". Hence one connection, `max: 1`, ended in a `finally`.
 *
 * Only ever runs at provisioning time, which is rare enough that the race with a member running `createdb`
 * in the same second is worth naming and not worth engineering around: it surfaces as a retryable error on
 * their side.
 */
export async function ensureTemplateIsolation(): Promise<{ ok: true } | { ok: false; error: string }> {
  const url = process.env.POSTGRES_URL;
  if (!url) return { ok: false, error: 'POSTGRES_URL is not set' };

  let target: string;
  try {
    const parsed = new URL(url);
    parsed.pathname = '/template1';
    target = parsed.toString();
  } catch {
    return { ok: false, error: 'POSTGRES_URL could not be parsed' };
  }

  const client = postgres(target, { max: 1, idle_timeout: 5, connection: { application_name: 'officer-provision' } });
  try {
    for (const statement of TEMPLATE_ISOLATION_SQL) await client.unsafe(statement);
    return { ok: true };
  } catch (ex) {
    return { ok: false, error: `could not install database isolation into template1: ${asMessage(ex)}` };
  } finally {
    await client.end({ timeout: 5 });
  }
}

export type PostgresRoleResult =
  | { ok: true; role: string; created: boolean; passwordSet: boolean }
  | { ok: false; error: string };

/**
 * Give the account a Postgres login role of the same name.
 *
 * Idempotent, and the two halves are idempotent differently. The ROLE is adopted if it exists. The
 * PASSWORD is only (re)set when `.pgpass` is missing, because we do not store it anywhere else — so a
 * reprovision of a working account must not rotate a credential the member may have pasted into an
 * application config. A missing `.pgpass` is the one case where rotating is the only way to get back to a
 * known state, and there is nothing to lose by then.
 *
 * Never throws. Same posture as its neighbours in `provisionOsAccount`: an account with no database role
 * is still a working account.
 */
export async function provisionPostgresRole(params: {
  email: string;
  osUser: string;
  uid: number;
  gid: number;
}): Promise<PostgresRoleResult> {
  // Both before anything else, and before the role can exist to take advantage of either being absent.
  const closed = await ensureAppDatabaseClosed();
  if (!closed.ok) return { ok: false, error: closed.error };

  const isolated = await ensureTemplateIsolation();
  if (!isolated.ok) return { ok: false, error: isolated.error };

  // Re-asserted here even though `validateUsername` already ran at the route. This string reaches SQL as an
  // identifier, and the distance between that check and this one is a whole call chain — the kind of gap
  // where a future caller arrives without having passed the first one.
  if (!ROLE_NAME_RE.test(params.osUser)) {
    return { ok: false, error: `'${params.osUser}' is not a usable Postgres role name` };
  }

  const home = osUserHome(params.email);
  const pgpass = join(home, '.pgpass');
  const zshenv = join(home, '.zshenv');

  try {
    const existing = await db.execute<{ rolname: string }>(
      sql`select rolname from pg_roles where rolname = ${params.osUser}`,
    );
    const created = existing.length === 0;

    // Tested AS THE MEMBER, through runAs — a 700 home is unreadable to the service user, so a `test -f`
    // run as ourselves reports "missing" for a file sitting right there, and would rotate their password on
    // every single reprovision.
    //
    // BOTH files, because the password is not stored anywhere else: if either is missing we cannot rewrite
    // it from the other, and rotating is the only route back to a state where the two agree.
    const present = await Promise.all(
      [pgpass, zshenv].map(async (path) => (await runAs(params.osUser, ['test', '-f', path]).exited) === 0),
    );

    const passwordSet = created || present.some((found) => !found);
    const password = passwordSet ? generatePassword() : null;

    if (created) {
      await db.execute(sql.raw(`CREATE ROLE "${params.osUser}" ${ROLE_ATTRIBUTES} PASSWORD '${password}'`));
    } else {
      // Re-asserted rather than assumed. An adopted role may have been altered by hand between runs, and
      // this is the only place that states what a member's role is allowed to be.
      await db.execute(sql.raw(`ALTER ROLE "${params.osUser}" ${ROLE_ATTRIBUTES}`));
      if (passwordSet) await db.execute(sql.raw(`ALTER ROLE "${params.osUser}" PASSWORD '${password}'`));
    }

    if (passwordSet && password) {
      const { host, port } = pgpassTarget(process.env.POSTGRES_URL);

      // Written twice, on purpose, and they are not redundant.
      //
      //   ~/.zshenv   the readable one. Asked for by name: somewhere obvious to look when you want to know
      //               your own credentials, rather than a dotfile you have to be told about. `.zshenv`
      //               rather than `.zshrc` for two reasons — zsh sources it for NON-interactive shells too,
      //               so a script, a cron entry or an agent turn gets it, and `.zshrc` is a shared template
      //               that `seedShellConfig` only updates while it still matches byte-for-byte. Writing a
      //               per-member password into that file would strand every member on the template they
      //               were created with.
      //
      //   ~/.pgpass   the one libpq reads on its own, with no shell involved at all. Anything that is not a
      //               zsh process — a systemd unit, python's psycopg, a compiled binary — finds this and
      //               nothing else.
      //
      // Both 600. psql ignores a .pgpass that is group- or world-readable, exactly like ssh and
      // authorized_keys, so there it is a requirement rather than caution.
      const files: Array<{ content: string; dest: string; label: string }> = [
        {
          // `*` for the database field: they may create as many as they like and every one of them is theirs.
          content: `${host}:${port}:*:${params.osUser}:${password}\n`,
          dest: pgpass,
          label: '~/.pgpass',
        },
        {
          content: [
            '# Officer — your Postgres credentials.',
            '#',
            '# Written when your account was created. Officer rewrites this file, so put your own settings',
            '# in ~/.zshrc.local instead.',
            '#',
            '# With these set, no flags are needed:  createdb myapp  •  psql myapp  •  dropdb myapp',
            '# Make as many databases as you like. They are yours, and nobody else can reach them.',
            `export PGHOST=${host}`,
            `export PGPORT=${port}`,
            `export PGUSER=${params.osUser}`,
            `export PGPASSWORD=${password}`,
            '',
          ].join('\n'),
          dest: zshenv,
          label: '~/.zshenv',
        },
      ];

      for (const file of files) {
        const written = await installFile({
          content: file.content,
          dest: file.dest,
          uid: params.uid,
          gid: params.gid,
          mode: '600',
        });
        if (!written.ok) {
          return {
            ok: false,
            error: `role ${params.osUser} exists but ${file.label} could not be written: ${written.out}`,
          };
        }
      }
    }

    return { ok: true, role: params.osUser, created, passwordSet };
  } catch (ex) {
    return { ok: false, error: `could not provision the Postgres role ${params.osUser}: ${asMessage(ex)}` };
  }
}

/**
 * Remove a member's role, and every database they own.
 *
 * `DROP ROLE` refuses while the role owns anything, which is the common case for exactly the accounts this
 * is called for. The databases go first and they go permanently — this is called from account deletion,
 * where the alternative is a cluster that accumulates unreachable databases owned by a name nobody holds.
 *
 * Deliberately NOT wired into `deprovisionOsAccount` yet: that function's contract is that a failure
 * leaves the account intact and retryable, and dropping databases is not reversible. See the call site.
 */
export type DropRoleResult = { ok: true; removed: boolean; reassigned: string[] } | { ok: false; error: string };

/** Legal database name. Separate from ROLE_NAME_RE because a member names these, not us. */
const DB_NAME_RE = /^[a-zA-Z0-9._-]{1,63}$/;

/**
 * Take a member's Postgres role away, keeping their data.
 *
 * ── Why this exists, and why it is not optional ──
 *
 * Exactly the trap `deprovisionOsAccount` was written for, in a different id space. `provisionPostgresRole`
 * ADOPTS a role that already exists — so a role left behind by a deleted member is inherited whole, with its
 * databases, by the next member who happens to get the same username. `useradd` hands out the lowest free
 * uid; the owner hands out usernames, and reusing one is likelier, not less.
 *
 * ── Preserve, not destroy ──
 *
 * Their databases are REASSIGNED to the platform role, not dropped. `severMemberTree` chowns a member's
 * files to the service user rather than deleting them, and a database is the same kind of thing: the owner
 * removing an account has not necessarily asked to destroy the work in it. Dropping is also the one choice
 * that cannot be walked back.
 *
 * The sequence per database is the documented idiom and both halves are needed:
 *
 *   REASSIGN OWNED BY <member> TO <platform>   moves the objects — tables, schemas, functions
 *   DROP OWNED BY <member>                     removes what is left, which after a reassign is only the
 *                                              privilege GRANTS. Without it `DROP ROLE` still refuses,
 *                                              because an ACL entry naming the role is a dependency too.
 *
 * It has to run INSIDE each database, because both statements act only on the one they are connected to —
 * hence a connection per database rather than a loop over `db`.
 */
export async function dropPostgresRole(osUser: string): Promise<DropRoleResult> {
  if (!ROLE_NAME_RE.test(osUser)) return { ok: false, error: `'${osUser}' is not a usable Postgres role name` };

  const url = process.env.POSTGRES_URL;
  if (!url) return { ok: false, error: 'POSTGRES_URL is not set' };

  try {
    const present = await db.execute<{ rolname: string }>(sql`select rolname from pg_roles where rolname = ${osUser}`);
    // Already gone is success, so a retry after a partial teardown finishes rather than refuses.
    if (present.length === 0) return { ok: true, removed: false, reassigned: [] };

    const [platform] = await db.execute<{ current_user: string }>(sql`select current_user`);
    const owner = platform?.current_user;
    if (!owner || !ROLE_NAME_RE.test(owner)) return { ok: false, error: 'could not determine the platform role' };

    const owned = await db.execute<{ datname: string }>(
      sql`select datname from pg_database where pg_get_userbyid(datdba) = ${osUser}`,
    );

    const reassigned: string[] = [];
    for (const row of owned) {
      if (!DB_NAME_RE.test(row.datname)) {
        return { ok: false, error: `refusing to touch database '${row.datname}': unexpected name` };
      }
      // The database itself first: REASSIGN OWNED does not move database ownership, only objects inside one.
      await db.execute(sql.raw(`ALTER DATABASE "${row.datname}" OWNER TO "${owner}"`));
      const moved = await reassignInsideDatabase({ url, database: row.datname, from: osUser, to: owner });
      if (!moved.ok) return moved;
      reassigned.push(row.datname);
    }

    // And in the platform's own database, where they own nothing but may still hold a grant.
    await db.execute(sql.raw(`REASSIGN OWNED BY "${osUser}" TO "${owner}"`));
    await db.execute(sql.raw(`DROP OWNED BY "${osUser}"`));

    await db.execute(sql.raw(`DROP ROLE IF EXISTS "${osUser}"`));
    return { ok: true, removed: true, reassigned };
  } catch (ex) {
    return { ok: false, error: `could not remove the Postgres role ${osUser}: ${asMessage(ex)}` };
  }
}

async function reassignInsideDatabase(params: {
  url: string;
  database: string;
  from: string;
  to: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
  let target: string;
  try {
    const parsed = new URL(params.url);
    parsed.pathname = `/${params.database}`;
    target = parsed.toString();
  } catch {
    return { ok: false, error: 'POSTGRES_URL could not be parsed' };
  }

  const client = postgres(target, { max: 1, idle_timeout: 5, connection: { application_name: 'officer-deprovision' } });
  try {
    await client.unsafe(`REASSIGN OWNED BY "${params.from}" TO "${params.to}"`);
    await client.unsafe(`DROP OWNED BY "${params.from}"`);
    return { ok: true };
  } catch (ex) {
    return { ok: false, error: `could not reassign objects in '${params.database}': ${asMessage(ex)}` };
  } finally {
    await client.end({ timeout: 5 });
  }
}
