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 { OS_USERS_ENABLED } from '@@/os-user';
import { deprovisionOsAccount } from '@@/os-user-deprovision';
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,
    // So the UI offers the Linux-account controls only where they can work. On a host without the feature
    // they would be a button that always reports the same refusal.
    osUsersEnabled: OS_USERS_ENABLED,
  });
};

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');

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

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 (OS_USERS_ENABLED && 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`,
      );
    }
  }

  // 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 });
};
