import type { Handler } from 'hono';
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
import type { UserRole } from 'officerdb';
import * as errors from '@@/custom-errors';
import { deprovisionOsAccount } from '@@/os-user-deprovision';
import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
import { lookupOsUser } from '@@/os-user';
import { DATA_PATH } from '@@/data-path';

// Owner-only management of the other accounts. Everything here is gated by ownerGate in
// users-router.ts; these handlers assume the caller is the Super Admin.
//
// The password hash never leaves this file — listing users is not a reason to hand them out.

type PublicUser = {
  id: number;
  email: string;
  name: string | null;
  username: string | null;
  avatar: string | null;
  status: string;
  role: string;
  createdAt: Date;
  /** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */
  isOwner: boolean;
  /** The Linux account this runs as, or null where per-user OS accounts are off. */
  osUser: string | null;
  /**
   * The public half of their generated SSH key. Listed because it has an errand attached — it must be
   * added to their Gitea account — and the create form promises it is retrievable here afterwards. Public
   * by definition, so no reason to withhold it from the owner-only endpoint that already returns emails.
   */
  osSshPublicKey: string | null;
};

/** Shared with create-user.ts, so a created account and a listed one are described the same way. */
export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({
  id: u.id,
  email: u.email,
  name: u.name,
  username: u.username,
  avatar: u.avatar,
  status: u.status,
  role: u.role,
  createdAt: u.createdAt,
  isOwner: u.id === OWNER_USER_ID,
  osUser: u.osUser,
  osSshPublicKey: u.osSshPublicKey,
});

export const listUsersHandler: Handler = async function (ctx) {
  const users = await getUsers();
  return ctx.json({
    users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
    // Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a
    // picker — both write paths refuse it — which is what `assignableRoles` is for.
    roles: USER_ROLES,
    assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
    ownerId: OWNER_USER_ID,
  });
};

export const updateUserRoleHandler: Handler = async function (ctx) {
  const id = Number(ctx.req.param('id'));
  if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');

  const { role } = ctx.get('body') as { role?: unknown };
  if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) {
    throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`);
  }

  // ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
  // message. A raw CHECK violation surfaces as a 500 with Postgres wording, which tells the person
  // clicking the dropdown nothing about why.
  if (id === OWNER_USER_ID && role !== 'Super Admin') {
    throw errors.FORBIDDEN('The server owner cannot be demoted.');
  }

  // And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
  // second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
  // owner by that role, so two holders make "who owns this server" a question the query plan answers.
  // It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
  // server over is a deliberate act, not a dropdown.
  if (id !== OWNER_USER_ID && role === 'Super Admin') {
    throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
  }

  const existing = await getUserById(id);
  if (!existing) throw errors.NOT_FOUND('User not found');

  // ── The database role follows the platform role ──
  //
  // Without this the gate in `rolePermitsDevTools` is decorative: it would decide what a Developer gets at
  // creation and then never look again, so demoting one would leave their Postgres role, their databases
  // and a working password in their ~/.zshenv. A permission that survives its own revocation is worse than
  // not having gated it, because the UI then says something untrue.
  //
  // REVOKE BEFORE RECORDING, grant after. Dropping first means a failure aborts with the role unchanged, so
  // the account still says Developer and the whole thing can be retried. Doing it the other way round would
  // leave a Member holding database access with nothing in the row to indicate it.
  const had = rolePermitsDevTools(existing.role);
  const wants = rolePermitsDevTools(role as UserRole);

  if (had && !wants && existing.osUser) {
    const dropped = await dropPostgresRole(existing.osUser);
    if (!dropped.ok) {
      console.error(`[users] could not revoke database access for ${existing.email}: ${dropped.error}`);
      throw errors.INTERNAL_SERVER_ERROR(
        `Could not revoke ${existing.email}'s database access: ${dropped.error} The role was NOT changed.`,
      );
    }
    if (dropped.reassigned.length) {
      // Their data is kept, exactly as on deletion — but it is now owned by the platform and they cannot
      // reach it, so say where it went rather than letting it look deleted.
      console.info(
        `[users] revoked ${existing.osUser}'s database role — ${dropped.reassigned.length} database(s) ` +
          `kept and reassigned to the platform: ${dropped.reassigned.join(', ')}`,
      );
    }
  }

  const updated = await updateUser(id, { role: role as UserRole });
  if (!updated) throw errors.NOT_FOUND('User not found');

  // Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has
  // already happened and is the thing the owner asked for. Retryable via POST /users/:id/provision-linux,
  // which reads the role from the row.
  let databaseError: string | null = null;
  if (!had && wants && existing.osUser) {
    const ids = await lookupOsUser(existing.osUser);
    if (!ids) {
      databaseError = `${existing.osUser} is not a Linux account on this machine, so it got no database role.`;
    } else {
      const granted = await provisionPostgresRole({
        email: existing.email,
        osUser: existing.osUser,
        uid: ids.uid,
        gid: ids.gid,
      });
      if (!granted.ok) databaseError = granted.error;
    }
    if (databaseError) console.warn(`[users] ${existing.email}: ${databaseError}`);
  }

  return ctx.json({ user: toPublicUser(updated), databaseError });
};

export const deleteUserHandler: Handler = async function (ctx) {
  const id = Number(ctx.req.param('id'));
  if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');

  // deleteUser() throws for the owner regardless; this turns it into a 403 with a sentence rather than
  // an unhandled error.
  if (id === OWNER_USER_ID) throw errors.FORBIDDEN('The server owner cannot be removed.');

  const existing = await getUserById(id);
  if (!existing) throw errors.NOT_FOUND('User not found');

  // ── The Linux side goes FIRST, and its failure stops the delete ──
  //
  // This used to be the whole handler: remove the row, cascade the database, done. Measured on the
  // production host on 2026-08-12, immediately after deleting a member through this route: their Linux
  // account was still alive with a working login shell, their rootless Docker daemon was still running a
  // healthy postgres container, and 454 MB of their data was intact — while the platform had forgotten
  // they existed. `useradd` hands out the lowest free uid, so that number was queued up to be reissued to
  // the next member along with everything still owned by it.
  //
  // Ordered this way round because the row is what remembers there is anything to clean up. Delete it
  // first and a failed deprovision is unrecoverable through the UI: no row, no osUser, nothing to retry
  // against. Keeping the account on failure is also the safer half of the trade — an account that still
  // exists is inert, whereas a freed uid whose files still carry it is the hazard itself.
  if (existing.osUser) {
    const deprovisioned = await deprovisionOsAccount({ email: existing.email, osUser: existing.osUser });
    if (!deprovisioned.ok) {
      // Loud on purpose. A missing Docker install warns into a log; this one names the account, the stage
      // and the freed range — which after a failed release is the only surviving record of it.
      console.error(
        `[users] DEPROVISION FAILED for ${existing.email} at stage '${deprovisioned.stage}': ${deprovisioned.error}` +
          (deprovisioned.freed
            ? ` — uid ${deprovisioned.freed.uid}, subuid ${deprovisioned.freed.subUid?.start ?? 'none'}` +
              ` ${deprovisioned.freed.subUid?.count ?? ''}`
            : ''),
      );
      throw errors.INTERNAL_SERVER_ERROR(
        `Could not remove ${existing.email}'s Linux account: ${deprovisioned.error} ` +
          `The platform account was NOT deleted, so this can be retried.`,
      );
    }
    for (const warning of deprovisioned.warnings) console.warn(`[users] ${existing.email}: ${warning}`);
    if (deprovisioned.freed) {
      // The audit line. `scripts/assert-uid-free.sh --check` takes exactly these arguments, and after
      // `userdel` this log is the only place the freed subuid range still exists.
      //
      // DATA_PATH is spelled out rather than left to the operator, because this line exists to be COPIED
      // and sudo's env_reset drops it — the version without it fell back to a hardcoded default and made
      // the checker report CLEAN without reading a single member tree.
      const { osUser, uid, subUid } = deprovisioned.freed;
      const range = subUid ? `${subUid.start} ${subUid.count}` : null;
      console.info(
        range
          ? `[users] deprovisioned ${osUser} — verify with: sudo DATA_PATH=${DATA_PATH} ` +
              `./scripts/assert-uid-free.sh --check ${osUser} ${uid} ${range}`
          : `[users] deprovisioned ${osUser} (uid ${uid}) — it had no /etc/subuid range, so only the uid ` +
              `half is verifiable: sudo DATA_PATH=${DATA_PATH} ./scripts/assert-uid-free.sh --check ` +
              `${osUser} ${uid} <start> <count> will refuse without real numbers`,
      );
    }
  }

  // ── The Postgres side, after the Linux side and before the row ──
  //
  // The same trap as the uid, in a different id space: `provisionPostgresRole` ADOPTS a role that already
  // exists, so a role left behind here is inherited whole — with its databases — by the next member who
  // gets the same username. The owner hands out usernames, so reuse is likelier than a reissued uid, not
  // less.
  //
  // Ordered after the Linux teardown deliberately. That step can fail and abort, and it must not abort
  // AFTER something irreversible has happened to their databases. Ordered before `deleteUser` for the
  // reason stated above: the row is what remembers there is anything left to clean up.
  //
  // Their data is kept — the databases are reassigned to the platform role, not dropped, the same way
  // `severMemberTree` chowns their files rather than deleting them.
  if (existing.osUser) {
    const pg = await dropPostgresRole(existing.osUser);
    if (!pg.ok) {
      console.error(`[users] POSTGRES DEPROVISION FAILED for ${existing.email}: ${pg.error}`);
      throw errors.INTERNAL_SERVER_ERROR(
        `Could not remove ${existing.email}'s Postgres role: ${pg.error} ` +
          `The platform account was NOT deleted, so this can be retried.`,
      );
    }
    if (pg.removed) {
      console.info(
        `[users] dropped Postgres role ${existing.osUser}` +
          (pg.reassigned.length
            ? ` — ${pg.reassigned.length} database(s) kept and reassigned: ${pg.reassigned.join(', ')}`
            : ' — it owned no databases'),
      );
    }
  }

  // Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed
  // to them. There is no undo, which is why the UI asks first.
  await deleteUser(id);
  return ctx.json({ ok: true });
};
