import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

// The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table,
// so the first account created is always 1. Exported so the CHECK below and the delete guard in
// queries/auth.ts state the number once between them.
export const OWNER_USER_ID = 1;

// Roles, in descending authority. Exported as a value so the API and the UI can enumerate them from
// the same place the column is defined — a second hand-written list of role names is how they drift.
//
// A text column with a TS enum rather than a Postgres enum type, matching `status` below: the set is
// still settling, and adding a role should be a one-line schema change that `db:push` applies, not an
// ALTER TYPE. Postgres will not reject an unknown string — the TS type is the guard.
//
// 'Super Admin' is the server owner, and this column is the ONLY thing that says so. isSuperAdmin()
// and getOwnerUser() both read it; there is no SUPER_ADMIN_EMAIL and no "first account wins" fallback
// any more. Two consequences worth holding on to: bootstrap must set it explicitly on the first
// account, and demoting the last Super Admin would leave the platform with no owner. The CHECK on the
// table below stops that for the bootstrap account specifically: user 1 cannot be anything else.
export const USER_ROLES = ['Super Admin', 'Admin', 'Member', 'Developer'] as const;
export type UserRole = (typeof USER_ROLES)[number];

export const users = pgTable(
  'users',
  {
    id: serial('id').primaryKey(),
    email: text('email').notNull().unique(),
    password: text('password'),
    status: text('status', { enum: ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] })
      .notNull()
      .default('Unverified'),
    // Defaults to the least privileged role so a row created by any path that does not think about
    // authorisation cannot accidentally mint an admin.
    role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
    name: text('name'),
    username: text('username').unique(),
    /**
     * The Linux account this platform account runs as, when per-user OS accounts are enabled.
     *
     * Stored rather than re-derived from `username`. `useradd` can adjust or refuse a name, and a derived
     * value would let the platform's idea of who someone is drift from what is actually in `/etc/passwd`
     * — which, for a field that decides whose uid executes a shell, is not a drift to discover later.
     *
     * NULL means no OS account: every account created before the feature, every account on a host where
     * it is switched off, and the owner (who runs as the service user itself).
     * See docs/per-user-linux-accounts.md.
     *
     * Uniqueness is a `uniqueIndex` below, NOT `.unique()` here. `.unique()` emits a named unique
     * CONSTRAINT, and drizzle-kit responds to a new one on a populated table by asking whether to TRUNCATE
     * — a prompt that cannot be answered in a non-interactive `db:push` and which stops the whole push.
     * Hit and reverted on 2026-08-11; same trap as the composite keys in databases/CLAUDE.md.
     */
    osUser: text('os_user'),
    /**
     * The PUBLIC half of the outbound SSH key generated in this account's home.
     *
     * Stored so the owner can retrieve it later — it has to be pasted into the member's Gitea account, and
     * the home is 700 so nothing can read it back off disk without root. Public by definition; the private
     * half never leaves the machine and is never in this database.
     */
    osSshPublicKey: text('os_ssh_public_key'),
    avatar: text('avatar'),
    passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    // Two accounts must not share one Linux user — that would make "whose uid is this" ambiguous for a
    // shell. An index rather than a constraint: see the note on the column.
    uniqueIndex('uq_users_os_user').on(table.osUser),
    // The owner cannot be demoted. Enforced here rather than in application code because the whole
    // point is that it holds "whatever happens" — a stray UPDATE, a migration script, someone at a psql
    // prompt. Postgres rejects the write; there is no path around it.
    //
    // A row-level CHECK can say "if this row is user 1 then its role is Super Admin". It cannot say
    // "some row must be Super Admin" — that would need to see other rows. So this pins the bootstrap
    // account and nothing more; promoting or demoting anyone else is still free.
    //
    // It cannot stop a DELETE either. deleteUser() in queries/auth.ts refuses id 1 for that half.
    //
    // sql.raw for the literal — an interpolated JS string binds as `$1`, and Postgres will not accept a
    // parameter inside a CHECK. Same trap as ck_server_integrations_provider.
    check(
      'ck_users_owner_is_super_admin',
      sql`${table.id} <> ${sql.raw(String(OWNER_USER_ID))} OR ${table.role} = ${sql.raw("'Super Admin'")}`,
    ),
  ],
);

export const passkeys = pgTable('passkeys', {
  id: serial('id').primaryKey(),
  userId: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  origin: text('origin'),
  credentialId: text('credential_id'),
  publicKey: text('public_key'),
  counter: integer('counter').notNull().default(0),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});

export const passkeyChallenges = pgTable('passkey_challenges', {
  id: serial('id').primaryKey(),
  userId: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  origin: text('origin').notNull(),
  challenge: text('challenge').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
});

export const tokenBlacklist = pgTable(
  'token_blacklist',
  {
    jti: text('jti').primaryKey(),
    expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
  },
  (table) => [index('idx_token_blacklist_expires').on(table.expiresAt)],
);
