import { Database } from 'bun:sqlite';
import { randomBytes } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';

// Every encryption and signing key the platform holds, one SQLite file, one key per purpose.
//
// Design and rationale: docs/secret-store.md. What follows is only what a caller needs.
//
// ── The file IS the secret ──
//
// Keys are stored as they are used. There is no second key that unlocks this file, because a key sitting
// beside the store it opens buys nothing — whoever can read one can read the other. The boundary is
// `0600`, owned by the service user, and the fact that only the processes that need a key ever open it.
//
// That is the point of the whole exercise, and it is about blast radius rather than secrecy: `.env` is
// auto-loaded by bun into ALL of the pm2 processes, so a key there is readable from `/proc/<pid>/environ`
// of twenty processes that mostly have no business with it. `officer-music` held the key that decrypts
// wallet seed envelopes. Read on demand, by the few that need it, is the fix.
//
// ── One key per purpose, not one key for everything ──
//
// This replaced a single VAULT_STORE_KEY that encrypted seven unrelated things — the Headscale admin
// credential, the wallet seed, Jellyfin tokens, Immich, InvoiceShelf, Vaultwarden's token set and every
// app-store upstream secret. One leaked key opened all of them, and the name pointed at whichever plugin
// happened to need it first, so nobody reading it could tell what changing it would destroy.
//
// Purposes are created on first use, so a plugin installed in six months finds the store already there
// and simply asks for its own. Nothing has to be provisioned in advance, and no plugin can read another's.
//
// ── Rotation ──
//
// Not implemented, but the schema is shaped for it: keys are rows with `retired_at`, and the partial
// unique index permits exactly one ACTIVE key per purpose while keeping the retired ones. A rotation
// retires the current key, inserts a new one, and re-encrypts; `retiredKeys()` is what lets a decrypt
// still succeed for rows written before it finished.

const STORE_DIR_MODE = 0o700;
const STORE_FILE_MODE = 0o600;

// The same derivation as src/servers/data-path.ts, and deliberately a second copy of that one line.
// It cannot be imported: this module is inside the `officerdb` package, and a package reaching back into
// `src/servers` inverts the dependency. Importing the other direction is worse — `officerdb`'s index
// pulls in db.ts, which opens a Postgres client at module load and throws without POSTGRES_URL, so
// `jwt.ts` asking for a key would drag a database connection into every process that signs a token.
const OFFICER_ROOT = resolve(process.cwd(), '..');

// NOT under `data/`. That directory holds managed homes and attachments — it is the one people back up,
// and a key store travelling in the same tarball as a database dump rebuilds the exact problem this
// exists to avoid. See docs/secret-store.md, decision 3.
const STORE_DIR = join(OFFICER_ROOT, 'secrets');
const STORE_PATH = join(STORE_DIR, 'officer-keys.db');

let db: Database | null = null;

function open(): Database {
  if (db) return db;

  mkdirSync(STORE_DIR, { recursive: true, mode: STORE_DIR_MODE });
  chmodSync(STORE_DIR, STORE_DIR_MODE);

  const handle = new Database(STORE_PATH, { create: true });

  // busy_timeout FIRST, and the order is the whole point. `journal_mode = WAL` takes an exclusive lock,
  // so with the default zero timeout it throws SQLITE_BUSY the moment another process holds the file —
  // measured with eight concurrent openers on bun 1.3.14, six died on this line. Every sidecar opens this
  // store at boot, so they open it simultaneously by definition, and the failure would have been most of
  // them refusing to start on a cold boot and none of them on a warm one.
  handle.exec('PRAGMA busy_timeout = 5000');

  // WAL because several sidecars hold this open at once, and the default rollback journal makes a reader
  // block a writer. Persistent once set, so this is a no-op on every open after the first.
  handle.exec('PRAGMA journal_mode = WAL');

  handle.exec(`
    CREATE TABLE IF NOT EXISTS keys (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      purpose TEXT NOT NULL,
      key TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      retired_at INTEGER
    )
  `);

  // One ACTIVE key per purpose. Partial, so retired keys accumulate beside it for rotation. This is also
  // what makes concurrent first-use safe: two processes racing to create the same purpose means one INSERT
  // fails, and the loser re-reads rather than minting a second key that would decrypt nothing.
  handle.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_keys_active_purpose ON keys(purpose) WHERE retired_at IS NULL');

  // Applied after creation: the file does not exist until the first statement runs.
  //
  // The sidecar files matter as much as the database. Measured on bun 1.3.10: enabling WAL creates
  // `-wal` and `-shm` at 0644 rather than inheriting the database's mode, and the WAL is where a freshly
  // written key actually lives — so a 0600 database beside a world-readable WAL protects nothing. The
  // 0700 directory is the real boundary and would cover it either way; these are set so that loosening
  // the directory later does not silently expose the keys.
  for (const path of [STORE_PATH, `${STORE_PATH}-wal`, `${STORE_PATH}-shm`]) {
    if (existsSync(path)) chmodSync(path, STORE_FILE_MODE);
  }

  db = handle;
  return db;
}

/** 32 bytes, base64url so it survives being put anywhere without quoting. */
function generate(): string {
  return randomBytes(32).toString('base64url');
}

function readActive(purpose: string): string | null {
  const row = open().query('SELECT key FROM keys WHERE purpose = ? AND retired_at IS NULL').get(purpose) as
    | { key: string }
    | undefined;
  return row?.key ?? null;
}

/**
 * The active key for a purpose, created on first use.
 *
 * Purposes are plain strings and belong to whoever owns the data they protect: `jwt`, `headscale`,
 * `wallet`, `photos`, `jellyfin`, `invoiceshelf`, `vault`, `service-connections`. A plugin asks for its
 * own and never another's — same rule `service_connections` rows already follow.
 *
 * Creating on demand means losing this file does not fail loudly, it mints new keys: every session is
 * invalidated and every encrypted column becomes unreadable. Back the file up, and see `hasKey` for the
 * callers that need to distinguish "no key yet" from "key exists".
 */
export function getKey(purpose: string): string {
  const existing = readActive(purpose);
  if (existing) return existing;

  const key = generate();
  try {
    open()
      .query('INSERT INTO keys (purpose, key, created_at) VALUES (?, ?, ?)')
      .run(purpose, key, Math.floor(Date.now() / 1000));
    return key;
  } catch {
    // Lost the race against another process. Its key is the real one — ours was never written and never
    // encrypted anything, so there is nothing to reconcile.
    const won = readActive(purpose);
    if (won) return won;
    throw new Error(`secret-store: could not create or read a key for '${purpose}'`);
  }
}

/** Whether a purpose has an active key, without creating one. */
export function hasKey(purpose: string): boolean {
  return readActive(purpose) !== null;
}

/**
 * Retired keys for a purpose, newest first. Empty until something rotates.
 *
 * A decrypt that fails against the active key should try these before giving up: during a rotation, rows
 * written before it started are still under the previous key.
 */
export function retiredKeys(purpose: string): string[] {
  const rows = open()
    .query('SELECT key FROM keys WHERE purpose = ? AND retired_at IS NOT NULL ORDER BY retired_at DESC')
    .all(purpose) as Array<{ key: string }>;
  return rows.map((r) => r.key);
}

/** Where the store lives. For the setup script and for error messages that need to name the file. */
export function secretStorePath(): string {
  return STORE_PATH;
}
