'use strict';

const crypto = require('crypto');
const { ALL_SCOPES } = require('./auth-rules');

const DEFAULT_CLAIM_TTL_MS = 10 * 60 * 1000;
const DEFAULT_CLAIM_RETENTION_MS = 24 * 60 * 60 * 1000;
const DEFAULT_PAIRING_REQUEST_TTL_MS = 10 * 60 * 1000;
const DEFAULT_PAIRING_REQUEST_RETENTION_MS = 24 * 60 * 60 * 1000;
// Device tokens expire after this much INACTIVITY (a sliding window refreshed on
// each use) and are hard-capped at DEVICE_TOKEN_ABSOLUTE_MAX_MS from issuance.
// A copied-but-unused token dies within the idle window; an actively used token
// is still bounded by the absolute cap, after which the device must re-pair.
// (Previously a flat 365 days from issuance with no renewal, so a leaked
// read-scope cookie was good for a year.)
const DEFAULT_DEVICE_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const DEVICE_TOKEN_ABSOLUTE_MAX_MS = 180 * 24 * 60 * 60 * 1000;
// Warn the phone this far before the absolute cap so re-pairing is a planned
// one-tap, not a surprise lockout.
const DEVICE_TOKEN_REPAIR_SOON_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_STEP_UP_TTL_MS = 10 * 60 * 1000;
const DEFAULT_WEBAUTHN_CHALLENGE_RETENTION_MS = 24 * 60 * 60 * 1000;
const RECENT_DEVICE_USE_MS = 2 * 60 * 1000;
// Sliding-renewal writes are throttled to at most once per window per token. The TTL is
// 30 days, so losing a minute of sliding precision is irrelevant — but skipping the write
// on the hot auth path keeps token validation a pure read and off the write lock.
const RENEWAL_WRITE_THROTTLE_MS = 60 * 1000;

function nowMs() {
  return Date.now();
}

function _envDaysMs(name, fallbackMs) {
  const days = Number(process.env[name]);
  return Number.isFinite(days) && days > 0 ? days * 24 * 60 * 60 * 1000 : fallbackMs;
}

// Both token windows are env-configurable (in DAYS). The absolute cap can never
// be shorter than the idle window.
function deviceTokenTtlMs() {
  return _envDaysMs('CTM_DEVICE_TOKEN_TTL_DAYS', DEFAULT_DEVICE_TOKEN_TTL_MS);
}

function deviceTokenAbsoluteMaxMs() {
  return Math.max(deviceTokenTtlMs(), _envDaysMs('CTM_DEVICE_TOKEN_MAX_DAYS', DEVICE_TOKEN_ABSOLUTE_MAX_MS));
}

function randomId(prefix, bytes = 16) {
  return `${prefix}_${crypto.randomBytes(bytes).toString('base64url')}`;
}

function hashValue(value) {
  return crypto.createHash('sha256').update(String(value || '')).digest('hex');
}

function normalizeScopes(scopes) {
  const out = [];
  const seen = new Set();
  const raw = Array.isArray(scopes) ? scopes : String(scopes || '').split(',');
  for (const item of raw) {
    const scope = String(item || '').trim();
    if (!ALL_SCOPES.includes(scope) || seen.has(scope)) continue;
    seen.add(scope);
    out.push(scope);
  }
  if (seen.has('admin')) {
    for (const scope of ALL_SCOPES) {
      if (!seen.has(scope)) out.push(scope);
    }
  }
  return out.length ? out : ['read', 'respond'];
}

function parseScopes(raw) {
  try {
    const parsed = Array.isArray(raw) ? raw : JSON.parse(raw || '[]');
    return normalizeScopes(parsed);
  } catch {
    return normalizeScopes(raw);
  }
}

function scopeSet(raw) {
  return new Set(parseScopes(raw));
}

function normalizeDeviceLabel(label) {
  const clean = String(label || '').trim().replace(/\s+/g, ' ');
  return clean ? clean.slice(0, 120) : 'Phone';
}

function deviceHintFromUserAgent(userAgent) {
  const ua = String(userAgent || '');
  let platform = '';
  if (/iPhone/i.test(ua)) platform = 'iPhone';
  else if (/iPad/i.test(ua)) platform = 'iPad';
  else if (/Android/i.test(ua)) platform = 'Android';
  else if (/Macintosh|Mac OS X/i.test(ua)) platform = 'Mac';
  else if (/Windows/i.test(ua)) platform = 'Windows';
  else if (/Linux/i.test(ua)) platform = 'Linux';
  let browser = '';
  if (/CriOS|Chrome/i.test(ua) && !/Edg/i.test(ua)) browser = 'Chrome';
  else if (/FxiOS|Firefox/i.test(ua)) browser = 'Firefox';
  else if (/EdgiOS|Edg\//i.test(ua)) browser = 'Edge';
  else if (/Safari/i.test(ua)) browser = 'Safari';
  return [platform, browser].filter(Boolean).join(' ');
}

function deviceProfileKey(device) {
  const label = normalizeDeviceLabel(device?.label || '').toLowerCase();
  const hint = deviceHintFromUserAgent(device?.last_used_ua || '').toLowerCase() || 'unknown';
  return `${label}|${hint}`;
}

function deviceProfileMatches(device, label, userAgent) {
  const deviceLabel = normalizeDeviceLabel(device?.label || '').toLowerCase();
  const targetLabel = normalizeDeviceLabel(label || '').toLowerCase();
  if (!deviceLabel || deviceLabel !== targetLabel) return false;
  const deviceHint = deviceHintFromUserAgent(device?.last_used_ua || '').toLowerCase();
  const targetHint = deviceHintFromUserAgent(userAgent || '').toLowerCase();
  if (!deviceHint || !targetHint) return true;
  return deviceHint === targetHint;
}

function authorizationStatus(device, atMs = nowMs()) {
  if (device.revoked_at) return 'revoked';
  if (device.expires_at && device.expires_at <= atMs) return 'expired';
  return 'authorized';
}

function connectionStatus(device, connectedSet, atMs = nowMs()) {
  const auth = authorizationStatus(device, atMs);
  if (auth !== 'authorized') return auth;
  if (connectedSet.has(String(device.id || ''))) return 'connected';
  if (device.last_used_at && atMs - Number(device.last_used_at) <= RECENT_DEVICE_USE_MS) return 'recent';
  return 'offline';
}

// Per-db-handle guard: the schema DDL (all CREATE … IF NOT EXISTS, idempotent) only needs
// to run once per process per connection. Re-running it on every call is what put every
// device-token validation through `db.exec` → the write lock, so a busy lock fail-fast threw
// out of the read path as `device_token_lookup_failed`. Keyed on the handle so a reinit
// (fresh db object) re-ensures automatically.
const _schemaEnsured = new WeakSet();

function ensureMobileAuthSchema(db) {
  if (_schemaEnsured.has(db)) return;
  db.exec(`
    CREATE TABLE IF NOT EXISTS ctm_device_tokens (
      id TEXT PRIMARY KEY,
      label TEXT NOT NULL,
      token_hash TEXT NOT NULL UNIQUE,
      scopes TEXT NOT NULL,
      webauthn_required INTEGER NOT NULL DEFAULT 1,
      created_at INTEGER NOT NULL,
      claimed_at INTEGER,
      passkey_bound_at INTEGER,
      last_used_at INTEGER,
      last_used_ip TEXT,
      last_used_ua TEXT,
      revoked_at INTEGER,
      expires_at INTEGER
    );

    CREATE TABLE IF NOT EXISTS ctm_device_claims (
      id TEXT PRIMARY KEY,
      secret_hash TEXT NOT NULL UNIQUE,
      label TEXT NOT NULL,
      scopes TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      expires_at INTEGER NOT NULL,
      claimed_at INTEGER,
      canceled_at INTEGER,
      device_token_id TEXT REFERENCES ctm_device_tokens(id),
      created_by TEXT NOT NULL DEFAULT 'loopback'
    );

    CREATE TABLE IF NOT EXISTS ctm_pairing_requests (
      id TEXT PRIMARY KEY,
      request_secret_hash TEXT NOT NULL UNIQUE,
      code TEXT NOT NULL,
      label TEXT NOT NULL,
      scopes TEXT NOT NULL,
      origin TEXT NOT NULL,
      remote_ip TEXT,
      user_agent TEXT,
      created_at INTEGER NOT NULL,
      expires_at INTEGER NOT NULL,
      approved_at INTEGER,
      rejected_at INTEGER,
      claim_id TEXT REFERENCES ctm_device_claims(id),
      created_by TEXT NOT NULL DEFAULT 'phone',
      decision_by TEXT
    );

    CREATE TABLE IF NOT EXISTS ctm_webauthn_credentials (
      id TEXT PRIMARY KEY,
      device_token_id TEXT NOT NULL REFERENCES ctm_device_tokens(id) ON DELETE CASCADE,
      rp_id TEXT NOT NULL,
      origin TEXT NOT NULL,
      credential_id TEXT NOT NULL UNIQUE,
      public_key BLOB NOT NULL,
      counter INTEGER NOT NULL DEFAULT 0,
      transports TEXT,
      created_at INTEGER NOT NULL,
      last_used_at INTEGER
    );

    CREATE TABLE IF NOT EXISTS ctm_webauthn_challenges (
      id TEXT PRIMARY KEY,
      kind TEXT NOT NULL,
      device_token_id TEXT,
      claim_id TEXT,
      challenge TEXT NOT NULL,
      rp_id TEXT NOT NULL,
      origin TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      expires_at INTEGER NOT NULL,
      consumed_at INTEGER
    );

    CREATE TABLE IF NOT EXISTS ctm_step_up_sessions (
      id_hash TEXT PRIMARY KEY,
      device_token_id TEXT NOT NULL REFERENCES ctm_device_tokens(id) ON DELETE CASCADE,
      created_at INTEGER NOT NULL,
      expires_at INTEGER NOT NULL,
      used_for_audit_id TEXT
    );

    CREATE TABLE IF NOT EXISTS ctm_auth_audit (
      id TEXT PRIMARY KEY,
      device_token_id TEXT,
      action TEXT NOT NULL,
      route TEXT,
      ws_type TEXT,
      scope TEXT,
      decision TEXT NOT NULL,
      code TEXT,
      remote_ip TEXT,
      user_agent TEXT,
      created_at INTEGER NOT NULL,
      details TEXT
    );

    CREATE INDEX IF NOT EXISTS idx_ctm_device_tokens_hash ON ctm_device_tokens(token_hash);
    CREATE INDEX IF NOT EXISTS idx_ctm_device_claims_secret ON ctm_device_claims(secret_hash);
    CREATE INDEX IF NOT EXISTS idx_ctm_pairing_requests_created ON ctm_pairing_requests(created_at DESC);
    CREATE INDEX IF NOT EXISTS idx_ctm_pairing_requests_pending ON ctm_pairing_requests(expires_at, approved_at, rejected_at);
    CREATE INDEX IF NOT EXISTS idx_ctm_webauthn_device ON ctm_webauthn_credentials(device_token_id);
    CREATE INDEX IF NOT EXISTS idx_ctm_webauthn_challenge_lookup ON ctm_webauthn_challenges(kind, device_token_id, claim_id);
    CREATE INDEX IF NOT EXISTS idx_ctm_step_up_device ON ctm_step_up_sessions(device_token_id);
    CREATE INDEX IF NOT EXISTS idx_ctm_auth_audit_created ON ctm_auth_audit(created_at DESC);
  `);

  const claimColumns = new Set(db.prepare('PRAGMA table_info(ctm_device_claims)').all().map((row) => row.name));
  if (!claimColumns.has('origin')) {
    db.prepare("ALTER TABLE ctm_device_claims ADD COLUMN origin TEXT NOT NULL DEFAULT ''").run();
  }
  // Mark ensured only after the DDL + migration succeed — if a busy write lock throws above,
  // we leave the handle unmarked so the next call retries instead of skipping a real migration.
  _schemaEnsured.add(db);
}

function deviceFromRow(row) {
  if (!row) return null;
  return {
    id: row.id,
    label: row.label,
    scopes: parseScopes(row.scopes),
    webauthn_required: !!row.webauthn_required,
    created_at: row.created_at,
    claimed_at: row.claimed_at || null,
    passkey_bound_at: row.passkey_bound_at || null,
    last_used_at: row.last_used_at || null,
    last_used_ip: row.last_used_ip || '',
    last_used_ua: row.last_used_ua || '',
    revoked_at: row.revoked_at || null,
    expires_at: row.expires_at || null,
  };
}

function claimFromRow(row) {
  if (!row) return null;
  return {
    id: row.id,
    label: row.label,
    scopes: parseScopes(row.scopes),
    origin: row.origin || '',
    created_at: row.created_at,
    expires_at: row.expires_at,
    claimed_at: row.claimed_at || null,
    canceled_at: row.canceled_at || null,
    device_token_id: row.device_token_id || null,
  };
}

function pairingRequestStatus(row, atMs = nowMs()) {
  if (!row) return 'not_found';
  if (row.rejected_at) return 'rejected';
  if (row.expires_at <= atMs) return 'expired';
  if (row.approved_at) return 'approved';
  return 'pending';
}

function pairingRequestFromRow(row, atMs = nowMs()) {
  if (!row) return null;
  return {
    id: row.id,
    code: row.code,
    label: row.label,
    scopes: parseScopes(row.scopes),
    origin: row.origin,
    remote_ip: row.remote_ip || '',
    user_agent: row.user_agent || '',
    device_hint: deviceHintFromUserAgent(row.user_agent || ''),
    created_at: row.created_at,
    expires_at: row.expires_at,
    approved_at: row.approved_at || null,
    rejected_at: row.rejected_at || null,
    claim_id: row.claim_id || null,
    created_by: row.created_by || 'phone',
    decision_by: row.decision_by || '',
    status: pairingRequestStatus(row, atMs),
  };
}

function credentialFromRow(row) {
  if (!row) return null;
  return {
    id: row.id,
    device_token_id: row.device_token_id,
    rp_id: row.rp_id,
    origin: row.origin,
    credential_id: row.credential_id,
    public_key: row.public_key,
    counter: Number(row.counter || 0),
    transports: parseTransports(row.transports),
    created_at: row.created_at,
    last_used_at: row.last_used_at || null,
  };
}

function createDeviceClaim(db, options = {}) {
  ensureMobileAuthSchema(db);
  cleanupExpiredDeviceClaims(db);
  const origin = new URL(options.origin || 'http://localhost:3456').origin;
  const id = randomId('claim');
  const secret = options.secret ? String(options.secret) : randomId('claim_secret', 24);
  const createdAt = nowMs();
  const expiresAt = createdAt + Math.max(1000, Number(options.ttlMs || DEFAULT_CLAIM_TTL_MS));
  const scopes = normalizeScopes(options.scopes);
  const label = normalizeDeviceLabel(options.label);

  db.prepare(`
    INSERT INTO ctm_device_claims (id, secret_hash, label, scopes, origin, created_at, expires_at, created_by)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  `).run(id, hashValue(secret), label, JSON.stringify(scopes), origin, createdAt, expiresAt, options.createdBy || 'loopback');

  const claimUrl = new URL('/m/claim', origin);
  claimUrl.searchParams.set('claim', id);
  claimUrl.searchParams.set('secret', secret);
  return { id, secret, claimUrl: claimUrl.toString(), label, scopes, origin, expiresAt };
}

function cleanupExpiredPairingRequests(db, options = {}) {
  ensureMobileAuthSchema(db);
  const atMs = Number(options.nowMs || nowMs());
  const retentionMs = Math.max(0, Number(options.retentionMs ?? DEFAULT_PAIRING_REQUEST_RETENTION_MS));
  const cutoff = atMs - retentionMs;
  const limit = Math.max(1, Math.min(1000, Number(options.limit || 250)));
  const rows = db.prepare(`
    SELECT id FROM ctm_pairing_requests
    WHERE (
      claim_id IS NULL
      OR approved_at IS NOT NULL
      OR rejected_at IS NOT NULL
      OR expires_at <= ?
    )
      AND (
        (expires_at > 0 AND expires_at <= ?)
        OR (approved_at IS NOT NULL AND approved_at > 0 AND approved_at <= ?)
        OR (rejected_at IS NOT NULL AND rejected_at > 0 AND rejected_at <= ?)
      )
    ORDER BY expires_at ASC
    LIMIT ?
  `).all(atMs, cutoff, cutoff, cutoff, limit);
  if (!rows.length) return { deleted: 0 };
  const del = db.prepare('DELETE FROM ctm_pairing_requests WHERE id = ?');
  const txn = db.transaction((items) => {
    for (const row of items) del.run(row.id);
  });
  txn(rows);
  return { deleted: rows.length };
}

function createPairingRequest(db, options = {}) {
  ensureMobileAuthSchema(db);
  cleanupExpiredPairingRequests(db);
  const origin = new URL(options.origin || 'http://localhost:3456').origin;
  const remoteIp = String(options.remoteIp || '');
  const userAgent = String(options.userAgent || '');
  const at = nowMs();
  const activeWindowMs = Math.max(1000, Number(options.activeWindowMs || DEFAULT_PAIRING_REQUEST_TTL_MS));
  const maxActive = Math.max(1, Math.min(25, Number(options.maxActive || 5)));
  const recentCount = db.prepare(`
    SELECT COUNT(*) AS count FROM ctm_pairing_requests
    WHERE created_at >= ?
      AND expires_at > ?
      AND approved_at IS NULL
      AND rejected_at IS NULL
      AND (
        (? <> '' AND remote_ip = ?)
        OR origin = ?
      )
  `).get(at - activeWindowMs, at, remoteIp, remoteIp, origin)?.count || 0;
  if (recentCount >= maxActive) throw new Error('pairing_request_rate_limited');

  const id = randomId('pair');
  const secret = randomId('pair_secret', 24);
  const code = String(crypto.randomInt(100000, 1000000));
  const hint = deviceHintFromUserAgent(userAgent);
  const label = normalizeDeviceLabel(options.label || hint || 'Phone');
  const scopes = normalizeScopes(options.scopes || ['read', 'respond']);
  const expiresAt = at + Math.max(1000, Number(options.ttlMs || DEFAULT_PAIRING_REQUEST_TTL_MS));
  db.prepare(`
    INSERT INTO ctm_pairing_requests
      (id, request_secret_hash, code, label, scopes, origin, remote_ip, user_agent, created_at, expires_at, created_by)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(id, hashValue(secret), code, label, JSON.stringify(scopes), origin, remoteIp, userAgent, at, expiresAt, options.createdBy || 'phone');
  return { request: pairingRequestFromRow({
    id,
    request_secret_hash: hashValue(secret),
    code,
    label,
    scopes: JSON.stringify(scopes),
    origin,
    remote_ip: remoteIp,
    user_agent: userAgent,
    created_at: at,
    expires_at: expiresAt,
    created_by: options.createdBy || 'phone',
  }, at), secret };
}

function listPairingRequests(db, options = {}) {
  ensureMobileAuthSchema(db);
  cleanupExpiredPairingRequests(db);
  const at = Number(options.nowMs || nowMs());
  const limit = Math.max(1, Math.min(100, Number(options.limit || 20)));
  const includeResolved = !!options.includeResolved;
  const rows = includeResolved
    ? db.prepare('SELECT * FROM ctm_pairing_requests ORDER BY created_at DESC LIMIT ?').all(limit)
    : db.prepare(`
        SELECT * FROM ctm_pairing_requests
        WHERE expires_at > ?
          AND approved_at IS NULL
          AND rejected_at IS NULL
        ORDER BY created_at DESC
        LIMIT ?
      `).all(at, limit);
  return rows.map((row) => pairingRequestFromRow(row, at));
}

function verifyPairingRequestSecret(db, requestId, secret, atMs = nowMs()) {
  ensureMobileAuthSchema(db);
  const row = db.prepare('SELECT * FROM ctm_pairing_requests WHERE id = ?').get(String(requestId || ''));
  if (!row || row.request_secret_hash !== hashValue(secret)) throw new Error('pairing_request_not_found');
  return pairingRequestFromRow(row, atMs);
}

function approvePairingRequest(db, requestId, options = {}) {
  ensureMobileAuthSchema(db);
  const id = String(requestId || '');
  const at = nowMs();
  const row = db.prepare('SELECT * FROM ctm_pairing_requests WHERE id = ?').get(id);
  if (!row) throw new Error('pairing_request_not_found');
  const status = pairingRequestStatus(row, at);
  if (status === 'expired') throw new Error('pairing_request_expired');
  if (status === 'rejected') throw new Error('pairing_request_rejected');
  const nextLabel = Object.prototype.hasOwnProperty.call(options, 'label')
    ? normalizeDeviceLabel(options.label)
    : row.label;
  const nextScopes = Object.prototype.hasOwnProperty.call(options, 'scopes')
    ? normalizeScopes(options.scopes)
    : parseScopes(row.scopes);
  const nextExpiresAt = Math.max(Number(row.expires_at || 0), at + DEFAULT_CLAIM_TTL_MS);
  db.prepare(`
    UPDATE ctm_pairing_requests
    SET approved_at = COALESCE(approved_at, ?),
        rejected_at = NULL,
        expires_at = ?,
        label = ?,
        scopes = ?,
        decision_by = ?
    WHERE id = ?
  `).run(at, nextExpiresAt, nextLabel, JSON.stringify(nextScopes), options.decisionBy || 'loopback', id);
  return pairingRequestFromRow({
    ...row,
    approved_at: row.approved_at || at,
    rejected_at: null,
    expires_at: nextExpiresAt,
    label: nextLabel,
    scopes: JSON.stringify(nextScopes),
    decision_by: options.decisionBy || 'loopback',
  }, at);
}

function rejectPairingRequest(db, requestId, options = {}) {
  ensureMobileAuthSchema(db);
  const id = String(requestId || '');
  const at = nowMs();
  const row = db.prepare('SELECT * FROM ctm_pairing_requests WHERE id = ?').get(id);
  if (!row) throw new Error('pairing_request_not_found');
  db.prepare(`
    UPDATE ctm_pairing_requests
    SET rejected_at = COALESCE(rejected_at, ?),
        decision_by = ?
    WHERE id = ? AND approved_at IS NULL
  `).run(at, options.decisionBy || 'loopback', id);
  return pairingRequestFromRow({ ...row, rejected_at: row.rejected_at || at, decision_by: options.decisionBy || 'loopback' }, at);
}

function pairingClaimSecret(requestSecret, request) {
  return crypto.createHmac('sha256', String(requestSecret || ''))
    .update(`${request.id}:${request.approved_at || 0}:ctm-phone-claim`)
    .digest('base64url')
    .slice(0, 40);
}

function ensurePairingRequestClaim(db, requestId, requestSecret, options = {}) {
  ensureMobileAuthSchema(db);
  const request = verifyPairingRequestSecret(db, requestId, requestSecret);
  if (request.status !== 'approved') return { status: request.status, request };
  const secret = pairingClaimSecret(requestSecret, request);
  let claimId = request.claim_id;
  if (!claimId) {
    const claim = createDeviceClaim(db, {
      label: request.label,
      scopes: request.scopes,
      origin: request.origin,
      secret,
      ttlMs: options.claimTtlMs || DEFAULT_CLAIM_TTL_MS,
      createdBy: `pairing_request:${request.id}`,
    });
    claimId = claim.id;
    db.prepare('UPDATE ctm_pairing_requests SET claim_id = COALESCE(claim_id, ?) WHERE id = ?')
      .run(claimId, request.id);
  }
  const claimUrl = new URL('/m/claim', request.origin);
  claimUrl.searchParams.set('claim', claimId);
  claimUrl.searchParams.set('secret', secret);
  return {
    status: 'approved',
    request: { ...request, claim_id: claimId },
    claim: {
      id: claimId,
      claimUrl: claimUrl.toString(),
      label: request.label,
      scopes: request.scopes,
      origin: request.origin,
    },
  };
}

function verifyDeviceClaimSecret(db, claimId, secret, atMs = nowMs()) {
  ensureMobileAuthSchema(db);
  const row = db.prepare('SELECT * FROM ctm_device_claims WHERE id = ?').get(String(claimId || ''));
  if (!row || row.secret_hash !== hashValue(secret)) throw new Error('claim_not_found');
  if (row.canceled_at) throw new Error('claim_canceled');
  if (row.claimed_at) throw new Error('claim_already_used');
  if (row.expires_at <= atMs) throw new Error('claim_expired');
  return claimFromRow(row);
}

function cancelDeviceClaim(db, claimId) {
  ensureMobileAuthSchema(db);
  return db.prepare(`
    UPDATE ctm_device_claims
    SET canceled_at = COALESCE(canceled_at, ?)
    WHERE id = ? AND claimed_at IS NULL
  `).run(nowMs(), String(claimId || ''));
}

function updateDeviceClaimScopes(db, claimId, scopes, atMs = nowMs()) {
  return updateDeviceClaim(db, claimId, { scopes }, atMs);
}

function updateDeviceClaim(db, claimId, patch = {}, atMs = nowMs()) {
  ensureMobileAuthSchema(db);
  const id = String(claimId || '');
  const row = db.prepare('SELECT * FROM ctm_device_claims WHERE id = ?').get(id);
  if (!row) throw new Error('claim_not_found');
  if (row.canceled_at) throw new Error('claim_canceled');
  if (row.claimed_at) throw new Error('claim_already_used');
  if (row.expires_at <= atMs) throw new Error('claim_expired');
  const nextScopes = Object.prototype.hasOwnProperty.call(patch, 'scopes')
    ? normalizeScopes(patch.scopes)
    : parseScopes(row.scopes);
  const nextLabel = Object.prototype.hasOwnProperty.call(patch, 'label')
    ? normalizeDeviceLabel(patch.label)
    : row.label;
  db.prepare('UPDATE ctm_device_claims SET label = ?, scopes = ? WHERE id = ?')
    .run(nextLabel, JSON.stringify(nextScopes), id);
  return claimFromRow({ ...row, label: nextLabel, scopes: JSON.stringify(nextScopes) });
}

function finishDeviceClaim(db, options = {}) {
  ensureMobileAuthSchema(db);
  const claimId = String(options.claimId || '');
  const secret = String(options.secret || '');
  const credential = options.credential || {};
  const issuedAt = nowMs();
  const claim = verifyDeviceClaimSecret(db, claimId, secret, issuedAt);
  const deviceId = randomId('device');
  const token = randomId('ctm_device_token', 32);
  const credentialId = String(credential.id || '');
  if (!credentialId) throw new Error('credential_id_required');
  const publicKey = Buffer.isBuffer(credential.publicKey)
    ? credential.publicKey
    : Buffer.from(credential.publicKey || '');
  if (!publicKey.length) throw new Error('credential_public_key_required');
  const rpId = String(options.rpId || '').trim();
  const origin = String(options.origin || '').trim();
  if (!rpId || !origin) throw new Error('credential_origin_required');
  const remoteIp = options.remoteIp || '';
  const userAgent = options.userAgent || '';

  const tx = db.transaction(() => {
    const fresh = db.prepare('SELECT claimed_at FROM ctm_device_claims WHERE id = ?').get(claimId);
    if (!fresh || fresh.claimed_at) throw new Error('claim_already_used');
    db.prepare(`
      INSERT INTO ctm_device_tokens
        (id, label, token_hash, scopes, webauthn_required, created_at, claimed_at, passkey_bound_at, last_used_at, last_used_ip, last_used_ua, expires_at)
      VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
    `).run(
      deviceId,
      claim.label,
      hashValue(token),
      JSON.stringify(claim.scopes),
      issuedAt,
      issuedAt,
      issuedAt,
      issuedAt,
      remoteIp,
      userAgent,
      issuedAt + deviceTokenTtlMs(),
    );
    db.prepare(`
      INSERT INTO ctm_webauthn_credentials
        (id, device_token_id, rp_id, origin, credential_id, public_key, counter, transports, created_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run(
      randomId('webauthn'),
      deviceId,
      rpId,
      origin,
      credentialId,
      publicKey,
      Number(credential.counter || 0),
      JSON.stringify(Array.isArray(credential.transports) ? credential.transports : []),
      issuedAt,
    );
    db.prepare(`
      UPDATE ctm_device_claims SET claimed_at = ?, device_token_id = ? WHERE id = ?
    `).run(issuedAt, deviceId, claimId);
    insertAudit(db, {
      deviceTokenId: deviceId,
      action: 'claim_finish',
      decision: 'allow',
      remoteIp,
      userAgent,
    });
  });
  tx();

  return {
    token,
    device: getDeviceToken(db, deviceId),
  };
}

function listClaimRecoveryCredentials(db, options = {}) {
  ensureMobileAuthSchema(db);
  const atMs = Number(options.nowMs || nowMs());
  const label = normalizeDeviceLabel(options.label || '');
  const userAgent = String(options.userAgent || '');
  const rpId = String(options.rpId || '').trim();
  if (!label || !rpId) return [];
  const rows = db.prepare(`
    SELECT
      d.id AS device_id,
      d.label,
      d.scopes,
      d.webauthn_required,
      d.created_at AS device_created_at,
      d.claimed_at,
      d.passkey_bound_at,
      d.last_used_at AS device_last_used_at,
      d.last_used_ip,
      d.last_used_ua,
      d.revoked_at,
      d.expires_at,
      c.id AS credential_row_id,
      c.rp_id,
      c.origin,
      c.credential_id,
      c.public_key,
      c.counter,
      c.transports,
      c.created_at AS credential_created_at,
      c.last_used_at AS credential_last_used_at
    FROM ctm_device_tokens d
    JOIN ctm_webauthn_credentials c ON c.device_token_id = d.id
    WHERE c.rp_id = ?
      AND d.revoked_at IS NULL
      AND (d.expires_at IS NULL OR d.expires_at > ?)
    ORDER BY COALESCE(d.last_used_at, d.created_at) DESC, c.created_at DESC
  `).all(rpId, atMs);
  const out = [];
  for (const row of rows) {
    const device = deviceFromRow({
      id: row.device_id,
      label: row.label,
      scopes: row.scopes,
      webauthn_required: row.webauthn_required,
      created_at: row.device_created_at,
      claimed_at: row.claimed_at,
      passkey_bound_at: row.passkey_bound_at,
      last_used_at: row.device_last_used_at,
      last_used_ip: row.last_used_ip,
      last_used_ua: row.last_used_ua,
      revoked_at: row.revoked_at,
      expires_at: row.expires_at,
    });
    if (!deviceProfileMatches(device, label, userAgent)) continue;
    out.push({
      device,
      credential: credentialFromRow({
        id: row.credential_row_id,
        device_token_id: row.device_id,
        rp_id: row.rp_id,
        origin: row.origin,
        credential_id: row.credential_id,
        public_key: row.public_key,
        counter: row.counter,
        transports: row.transports,
        created_at: row.credential_created_at,
        last_used_at: row.credential_last_used_at,
      }),
    });
  }
  return out;
}

function listActiveDeviceProfiles(db, options = {}) {
  ensureMobileAuthSchema(db);
  const atMs = Number(options.nowMs || nowMs());
  const label = normalizeDeviceLabel(options.label || '');
  const userAgent = String(options.userAgent || '');
  const rows = db.prepare(`
    SELECT * FROM ctm_device_tokens
    WHERE revoked_at IS NULL
      AND (expires_at IS NULL OR expires_at > ?)
    ORDER BY COALESCE(last_used_at, created_at) DESC
  `).all(atMs).map(deviceFromRow);
  return rows.filter((device) => deviceProfileMatches(device, label, userAgent));
}

function getClaimRecoveryCredential(db, options = {}) {
  const credentialId = String(options.credentialId || '');
  if (!credentialId) return null;
  return listClaimRecoveryCredentials(db, options)
    .find((entry) => entry.credential.credential_id === credentialId) || null;
}

function recoverDeviceClaim(db, options = {}) {
  ensureMobileAuthSchema(db);
  const claimId = String(options.claimId || '');
  const secret = String(options.secret || '');
  const deviceId = String(options.deviceId || '');
  const credentialId = String(options.credentialId || '');
  const recoveredAt = nowMs();
  const claim = verifyDeviceClaimSecret(db, claimId, secret, recoveredAt);
  const device = getDeviceToken(db, deviceId);
  if (!device) throw new Error('device_not_found');
  if (device.revoked_at) throw new Error('token_revoked');
  if (device.expires_at && device.expires_at <= recoveredAt) throw new Error('token_expired');
  if (!deviceProfileMatches(device, claim.label, options.userAgent || device.last_used_ua || '')) {
    throw new Error('device_profile_mismatch');
  }
  const credential = getCredentialForDevice(db, deviceId, credentialId);
  if (!credential) throw new Error('credential_not_found');
  const token = randomId('ctm_device_token', 32);
  const remoteIp = options.remoteIp || '';
  const userAgent = options.userAgent || device.last_used_ua || '';
  const tx = db.transaction(() => {
    const fresh = db.prepare('SELECT claimed_at FROM ctm_device_claims WHERE id = ?').get(claimId);
    if (!fresh || fresh.claimed_at) throw new Error('claim_already_used');
    db.prepare(`
      UPDATE ctm_device_tokens
      SET label = ?,
          scopes = ?,
          token_hash = ?,
          claimed_at = COALESCE(claimed_at, ?),
          passkey_bound_at = COALESCE(passkey_bound_at, ?),
          last_used_at = ?,
          last_used_ip = ?,
          last_used_ua = ?,
          expires_at = ?
      WHERE id = ?
    `).run(
      claim.label,
      JSON.stringify(claim.scopes),
      hashValue(token),
      recoveredAt,
      recoveredAt,
      recoveredAt,
      remoteIp,
      userAgent,
      recoveredAt + deviceTokenTtlMs(),
      deviceId,
    );
    db.prepare('DELETE FROM ctm_step_up_sessions WHERE device_token_id = ?').run(deviceId);
    db.prepare(`
      UPDATE ctm_device_claims SET claimed_at = ?, device_token_id = ? WHERE id = ?
    `).run(recoveredAt, deviceId, claimId);
    insertAudit(db, {
      deviceTokenId: deviceId,
      action: 'claim_recover',
      decision: 'allow',
      remoteIp,
      userAgent,
      details: { credentialId: credential.id, rpId: credential.rp_id },
    });
  });
  tx();
  return { token, device: getDeviceToken(db, deviceId), credential };
}

function getDeviceToken(db, deviceId) {
  ensureMobileAuthSchema(db);
  return deviceFromRow(db.prepare('SELECT * FROM ctm_device_tokens WHERE id = ?').get(String(deviceId || '')));
}

function listDeviceTokens(db) {
  ensureMobileAuthSchema(db);
  return db.prepare('SELECT * FROM ctm_device_tokens ORDER BY created_at DESC').all().map(deviceFromRow);
}

function listDeviceTokenDetails(db, options = {}) {
  ensureMobileAuthSchema(db);
  const atMs = Number(options.nowMs || nowMs());
  const connectedSet = new Set((options.connectedDeviceIds || []).map(String).filter(Boolean));
  const devices = listDeviceTokens(db);
  const credentials = db.prepare('SELECT * FROM ctm_webauthn_credentials ORDER BY created_at DESC').all();
  const credentialsByDevice = new Map();
  for (const credentialRow of credentials) {
    const deviceId = String(credentialRow.device_token_id || '');
    if (!credentialsByDevice.has(deviceId)) credentialsByDevice.set(deviceId, []);
    credentialsByDevice.get(deviceId).push(credentialFromRow(credentialRow));
  }

  const decorated = devices.map((device) => {
    const credentialsForDevice = credentialsByDevice.get(device.id) || [];
    const auth = authorizationStatus(device, atMs);
    const conn = connectionStatus(device, connectedSet, atMs);
    const groupKey = deviceProfileKey(device);
    return {
      ...device,
      authorization_status: auth,
      connection_status: conn,
      connected_now: conn === 'connected',
      recently_used: conn === 'recent',
      device_hint: deviceHintFromUserAgent(device.last_used_ua),
      credential_count: credentialsForDevice.length,
      credential_origins: Array.from(new Set(credentialsForDevice.map((credential) => credential.origin).filter(Boolean))),
      duplicate_group_id: `grp_${hashValue(groupKey).slice(0, 12)}`,
      duplicate_count: 1,
      duplicate_of: null,
      superseded_by: null,
    };
  });

  const byGroup = new Map();
  for (const device of decorated) {
    if (!byGroup.has(device.duplicate_group_id)) byGroup.set(device.duplicate_group_id, []);
    byGroup.get(device.duplicate_group_id).push(device);
  }
  const score = (device) => [
    device.authorization_status === 'authorized' ? 1 : 0,
    device.connected_now ? 1 : 0,
    Number(device.last_used_at || 0),
    Number(device.created_at || 0),
  ];
  for (const group of byGroup.values()) {
    if (group.length <= 1) continue;
    const primary = group.slice().sort((a, b) => {
      const as = score(a);
      const bs = score(b);
      for (let i = 0; i < as.length; i++) {
        if (bs[i] !== as[i]) return bs[i] - as[i];
      }
      return String(b.id).localeCompare(String(a.id));
    })[0];
    for (const device of group) {
      device.duplicate_count = group.length;
      if (device.id !== primary.id) {
        device.duplicate_of = primary.id;
        device.superseded_by = primary.id;
      }
    }
  }
  return decorated;
}

function updateDeviceToken(db, deviceId, patch = {}) {
  ensureMobileAuthSchema(db);
  const id = String(deviceId || '');
  const row = db.prepare('SELECT * FROM ctm_device_tokens WHERE id = ?').get(id);
  if (!row) throw new Error('device_not_found');
  if (row.revoked_at) throw new Error('device_revoked');
  const nextLabel = Object.prototype.hasOwnProperty.call(patch, 'label')
    ? normalizeDeviceLabel(patch.label)
    : row.label;
  const nextScopes = Object.prototype.hasOwnProperty.call(patch, 'scopes')
    ? normalizeScopes(patch.scopes)
    : parseScopes(row.scopes);
  db.prepare('UPDATE ctm_device_tokens SET label = ?, scopes = ? WHERE id = ?')
    .run(nextLabel, JSON.stringify(nextScopes), id);
  insertAudit(db, {
    deviceTokenId: id,
    action: 'device_update',
    decision: 'allow',
    details: { labelChanged: nextLabel !== row.label, scopesChanged: JSON.stringify(nextScopes) !== row.scopes },
  });
  return getDeviceToken(db, id);
}

function resolveDeviceToken(db, token, context = {}) {
  ensureMobileAuthSchema(db);
  const tokenHash = hashValue(token);
  const row = db.prepare('SELECT * FROM ctm_device_tokens WHERE token_hash = ?').get(tokenHash);
  const at = nowMs();
  if (!row) return { authenticated: false, code: token ? 'invalid_token' : 'missing_token' };
  if (row.revoked_at) return { authenticated: false, code: 'token_revoked', deviceId: row.id };
  // Hard cap: a device token cannot outlive the absolute window from issuance,
  // regardless of activity. After this the device must re-pair (fresh passkey).
  const absoluteDeadline = Number(row.created_at || 0) + deviceTokenAbsoluteMaxMs();
  if (row.created_at && at >= absoluteDeadline) {
    return { authenticated: false, code: 'token_expired', deviceId: row.id };
  }
  if (row.expires_at && row.expires_at <= at) return { authenticated: false, code: 'token_expired', deviceId: row.id };
  // Sliding renewal: extend the idle window on each use, bounded by the absolute
  // cap, so an active device stays paired while an abandoned (or quietly copied
  // and unused) token expires within the idle window.
  const slidExpiresAt = row.created_at
    ? Math.min(at + deviceTokenTtlMs(), absoluteDeadline)
    : at + deviceTokenTtlMs();
  // Throttle the renewal write: skip it only when it would be a no-op — the token was used
  // within the throttle window AND its expiry is already at the slide target. A burst of
  // requests (e.g. a phone loading a timeline full of image thumbnails) would otherwise fire
  // one write-lock acquisition per request; one per minute is plenty to keep the 30-day
  // sliding window alive. The expiry guard means a near-expiry token is still renewed even if
  // used recently. Already wrapped in try/catch so a busy lock is never fatal.
  const lastUsed = Number(row.last_used_at || 0);
  const currentExpiry = Number(row.expires_at || 0);
  const renewalRedundant = lastUsed
    && (at - lastUsed) <= RENEWAL_WRITE_THROTTLE_MS
    && currentExpiry >= (slidExpiresAt - RENEWAL_WRITE_THROTTLE_MS);
  if (!renewalRedundant) {
    try {
      db.prepare(`
        UPDATE ctm_device_tokens
        SET last_used_at = ?, last_used_ip = ?, last_used_ua = ?, expires_at = ?
        WHERE id = ?
      `).run(at, context.remoteIp || '', context.userAgent || '', slidExpiresAt, row.id);
    } catch {}
  }
  let stepUp = null;
  if (context.stepUpToken) {
    try { stepUp = validateStepUpSession(db, row.id, context.stepUpToken, at); } catch {}
  }
  return {
    authenticated: true,
    isLoopback: false,
    deviceId: row.id,
    scopes: scopeSet(row.scopes),
    stepUpFresh: !!stepUp,
    stepUpSessionHashPrefix: stepUp ? stepUp.id_hash.slice(0, 12) : '',
    tokenLabel: row.label,
    transport: context.transport || 'remote',
    source: 'device-token',
    tokenHashPrefix: tokenHash.slice(0, 12),
    device: deviceFromRow(row),
    // Absolute-cap visibility: lets the phone show "re-pair soon" while the
    // token still works instead of a surprise lockout at the hard deadline.
    hardExpiresAt: row.created_at ? absoluteDeadline : null,
    repairSoon: !!row.created_at && (absoluteDeadline - at) <= DEVICE_TOKEN_REPAIR_SOON_MS,
  };
}

function revokeDeviceToken(db, deviceId) {
  ensureMobileAuthSchema(db);
  const at = nowMs();
  const id = String(deviceId || '');
  const result = db.prepare(`
    UPDATE ctm_device_tokens
    SET revoked_at = COALESCE(revoked_at, ?)
    WHERE id = ?
  `).run(at, id);
  db.prepare('DELETE FROM ctm_step_up_sessions WHERE device_token_id = ?').run(id);
  insertAudit(db, { deviceTokenId: id, action: 'device_revoke', decision: 'allow' });
  return result;
}

function revokeDuplicateDeviceTokens(db, keepDeviceId, options = {}) {
  ensureMobileAuthSchema(db);
  const keepId = String(keepDeviceId || '');
  const devices = listDeviceTokenDetails(db, { nowMs: options.nowMs });
  const keep = devices.find((device) => device.id === keepId);
  if (!keep) throw new Error('device_not_found');
  const candidates = devices.filter((device) =>
    device.id !== keepId &&
    device.duplicate_group_id &&
    device.duplicate_group_id === keep.duplicate_group_id &&
    authorizationStatus(device, Number(options.nowMs || nowMs())) === 'authorized'
  );
  if (!candidates.length) return { count: 0, deviceIds: [] };
  const at = nowMs();
  const action = options.action || 'device_revoke_duplicate';
  const reason = options.reason || 'manual_keep_newest';
  const tx = db.transaction(() => {
    for (const device of candidates) {
      db.prepare(`
        UPDATE ctm_device_tokens
        SET revoked_at = COALESCE(revoked_at, ?)
        WHERE id = ?
      `).run(at, device.id);
      db.prepare('DELETE FROM ctm_step_up_sessions WHERE device_token_id = ?').run(device.id);
      insertAudit(db, {
        deviceTokenId: device.id,
        action,
        decision: 'allow',
        details: { keepDeviceId: keepId, reason },
      });
    }
  });
  tx();
  return { count: candidates.length, deviceIds: candidates.map((device) => device.id) };
}

function revokeAllDeviceTokens(db) {
  ensureMobileAuthSchema(db);
  const at = nowMs();
  const rows = db.prepare('SELECT id FROM ctm_device_tokens WHERE revoked_at IS NULL').all();
  const tx = db.transaction(() => {
    for (const row of rows) {
      db.prepare(`
        UPDATE ctm_device_tokens
        SET revoked_at = COALESCE(revoked_at, ?)
        WHERE id = ?
      `).run(at, row.id);
      db.prepare('DELETE FROM ctm_step_up_sessions WHERE device_token_id = ?').run(row.id);
      insertAudit(db, { deviceTokenId: row.id, action: 'device_revoke_all', decision: 'allow' });
    }
  });
  tx();
  return { count: rows.length, deviceIds: rows.map((row) => row.id) };
}

function cleanupExpiredDeviceClaims(db, options = {}) {
  ensureMobileAuthSchema(db);
  const atMs = Number(options.nowMs || nowMs());
  const retentionMs = Math.max(0, Number(options.retentionMs ?? DEFAULT_CLAIM_RETENTION_MS));
  const cutoff = atMs - retentionMs;
  const limit = Math.max(1, Math.min(1000, Number(options.limit || 250)));
  const rows = db.prepare(`
    SELECT id FROM ctm_device_claims
    WHERE device_token_id IS NULL
      AND claimed_at IS NULL
      AND (
        (expires_at > 0 AND expires_at <= ?)
        OR (canceled_at IS NOT NULL AND canceled_at > 0 AND canceled_at <= ?)
      )
    ORDER BY expires_at ASC
    LIMIT ?
  `).all(cutoff, cutoff, limit);
  if (!rows.length) return { deleted: 0 };
  const del = db.prepare('DELETE FROM ctm_device_claims WHERE id = ?');
  const txn = db.transaction((items) => {
    for (const row of items) del.run(row.id);
  });
  txn(rows);
  return { deleted: rows.length };
}

function cleanupExpiredWebAuthnChallenges(db, options = {}) {
  ensureMobileAuthSchema(db);
  const atMs = Number(options.nowMs || nowMs());
  const retentionMs = Math.max(0, Number(options.retentionMs ?? DEFAULT_WEBAUTHN_CHALLENGE_RETENTION_MS));
  const cutoff = atMs - retentionMs;
  const limit = Math.max(1, Math.min(1000, Number(options.limit || 250)));
  const rows = db.prepare(`
    SELECT id FROM ctm_webauthn_challenges
    WHERE expires_at <= ?
      AND (
        consumed_at IS NOT NULL
        OR expires_at <= ?
      )
    ORDER BY expires_at ASC
    LIMIT ?
  `).all(cutoff, cutoff, limit);
  if (!rows.length) return { deleted: 0 };
  const del = db.prepare('DELETE FROM ctm_webauthn_challenges WHERE id = ?');
  const txn = db.transaction((items) => {
    for (const row of items) del.run(row.id);
  });
  txn(rows);
  return { deleted: rows.length };
}

function cleanupMobileAuthArtifacts(db, options = {}) {
  ensureMobileAuthSchema(db);
  return {
    claims: cleanupExpiredDeviceClaims(db, options),
    pairing_requests: cleanupExpiredPairingRequests(db, options),
    webauthn_challenges: cleanupExpiredWebAuthnChallenges(db, options),
  };
}

function createStepUpSession(db, deviceId, options = {}) {
  ensureMobileAuthSchema(db);
  const id = String(deviceId || '');
  const device = getDeviceToken(db, id);
  if (!device) throw new Error('device_not_found');
  if (device.revoked_at) throw new Error('token_revoked');
  if (device.expires_at && device.expires_at <= nowMs()) throw new Error('token_expired');
  const token = randomId('ctm_step_up', 32);
  const idHash = hashValue(token);
  const createdAt = nowMs();
  const expiresAt = createdAt + Math.max(1000, Number(options.ttlMs || DEFAULT_STEP_UP_TTL_MS));
  db.prepare(`
    INSERT INTO ctm_step_up_sessions (id_hash, device_token_id, created_at, expires_at)
    VALUES (?, ?, ?, ?)
  `).run(idHash, id, createdAt, expiresAt);
  return {
    token,
    deviceId: id,
    idHashPrefix: idHash.slice(0, 12),
    createdAt,
    expiresAt,
  };
}

function validateStepUpSession(db, deviceId, token, atMs = nowMs()) {
  if (!deviceId || !token) return null;
  ensureMobileAuthSchema(db);
  const row = db.prepare(`
    SELECT * FROM ctm_step_up_sessions
    WHERE id_hash = ? AND device_token_id = ?
    LIMIT 1
  `).get(hashValue(token), String(deviceId || ''));
  if (!row || row.expires_at <= atMs) return null;
  return row;
}

function saveWebAuthnChallenge(db, options = {}) {
  ensureMobileAuthSchema(db);
  cleanupExpiredWebAuthnChallenges(db);
  const id = options.id || randomId('challenge');
  const createdAt = nowMs();
  db.prepare(`
    INSERT INTO ctm_webauthn_challenges
      (id, kind, device_token_id, claim_id, challenge, rp_id, origin, created_at, expires_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    id,
    String(options.kind || ''),
    options.deviceTokenId || null,
    options.claimId || null,
    String(options.challenge || ''),
    String(options.rpId || ''),
    String(options.origin || ''),
    createdAt,
    createdAt + Math.max(1000, Number(options.ttlMs || 5 * 60 * 1000)),
  );
  return { id, challenge: String(options.challenge || ''), createdAt };
}

function getWebAuthnChallenge(db, options = {}) {
  ensureMobileAuthSchema(db);
  const row = db.prepare(`
    SELECT * FROM ctm_webauthn_challenges
    WHERE kind = ?
      AND challenge = ?
      AND COALESCE(device_token_id, '') = COALESCE(?, '')
      AND COALESCE(claim_id, '') = COALESCE(?, '')
      AND consumed_at IS NULL
    ORDER BY created_at DESC
    LIMIT 1
  `).get(
    String(options.kind || ''),
    String(options.challenge || ''),
    options.deviceTokenId || null,
    options.claimId || null,
  );
  if (!row || row.expires_at <= nowMs()) return null;
  return row;
}

function consumeWebAuthnChallenge(db, options = {}) {
  ensureMobileAuthSchema(db);
  const kind = String(options.kind || '');
  const challenge = String(options.challenge || '');
  const at = nowMs();
  const row = db.prepare(`
    SELECT * FROM ctm_webauthn_challenges
    WHERE kind = ?
      AND challenge = ?
      AND COALESCE(device_token_id, '') = COALESCE(?, '')
      AND COALESCE(claim_id, '') = COALESCE(?, '')
      AND consumed_at IS NULL
    ORDER BY created_at DESC
    LIMIT 1
  `).get(kind, challenge, options.deviceTokenId || null, options.claimId || null);
  if (!row) throw new Error('challenge_not_found');
  if (row.expires_at <= at) throw new Error('challenge_expired');
  db.prepare('UPDATE ctm_webauthn_challenges SET consumed_at = ? WHERE id = ?').run(at, row.id);
  return row;
}

function listCredentialsForDevice(db, deviceId) {
  ensureMobileAuthSchema(db);
  return db.prepare('SELECT * FROM ctm_webauthn_credentials WHERE device_token_id = ? ORDER BY created_at DESC')
    .all(String(deviceId || ''))
    .map(credentialFromRow);
}

function getCredentialForDevice(db, deviceId, credentialId) {
  ensureMobileAuthSchema(db);
  return credentialFromRow(db.prepare(`
    SELECT * FROM ctm_webauthn_credentials
    WHERE device_token_id = ? AND credential_id = ?
    LIMIT 1
  `).get(String(deviceId || ''), String(credentialId || '')));
}

function registerWebAuthnCredential(db, deviceId, options = {}) {
  ensureMobileAuthSchema(db);
  const id = String(deviceId || '');
  const device = getDeviceToken(db, id);
  if (!device) throw new Error('device_not_found');
  if (device.revoked_at) throw new Error('token_revoked');
  const credential = options.credential || {};
  const credentialId = String(credential.id || '');
  if (!credentialId) throw new Error('credential_id_required');
  const publicKey = Buffer.isBuffer(credential.publicKey)
    ? credential.publicKey
    : Buffer.from(credential.publicKey || '');
  if (!publicKey.length) throw new Error('credential_public_key_required');
  const rpId = String(options.rpId || '').trim();
  const origin = String(options.origin || '').trim();
  if (!rpId || !origin) throw new Error('credential_origin_required');
  const rowId = options.id || randomId('webauthn');
  const at = nowMs();
  db.prepare(`
    INSERT INTO ctm_webauthn_credentials
      (id, device_token_id, rp_id, origin, credential_id, public_key, counter, transports, created_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    rowId,
    id,
    rpId,
    origin,
    credentialId,
    publicKey,
    Number(credential.counter || 0),
    JSON.stringify(Array.isArray(credential.transports) ? credential.transports : []),
    at,
  );
  db.prepare('UPDATE ctm_device_tokens SET passkey_bound_at = COALESCE(passkey_bound_at, ?) WHERE id = ?').run(at, id);
  insertAudit(db, { deviceTokenId: id, action: 'passkey_register', decision: 'allow', details: { rpId, origin } });
  return credentialFromRow(db.prepare('SELECT * FROM ctm_webauthn_credentials WHERE id = ?').get(rowId));
}

function updateCredentialCounter(db, credentialRowId, counter) {
  ensureMobileAuthSchema(db);
  const at = nowMs();
  return db.prepare(`
    UPDATE ctm_webauthn_credentials
    SET counter = ?, last_used_at = ?
    WHERE id = ?
  `).run(Number(counter || 0), at, String(credentialRowId || ''));
}

function parseTransports(raw) {
  try {
    const parsed = JSON.parse(raw || '[]');
    return Array.isArray(parsed) ? parsed.map(String) : [];
  } catch {
    return [];
  }
}

function insertAudit(db, entry = {}) {
  ensureMobileAuthSchema(db);
  const id = entry.id || randomId('audit');
  db.prepare(`
    INSERT INTO ctm_auth_audit
      (id, device_token_id, action, route, ws_type, scope, decision, code, remote_ip, user_agent, created_at, details)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    id,
    entry.deviceTokenId || null,
    String(entry.action || 'auth'),
    entry.route || '',
    entry.wsType || '',
    entry.scope || '',
    String(entry.decision || 'deny'),
    entry.code || '',
    entry.remoteIp || '',
    entry.userAgent || '',
    entry.createdAt || nowMs(),
    entry.details ? JSON.stringify(entry.details) : '',
  );
  return id;
}

function listAuthAudit(db, options = {}) {
  ensureMobileAuthSchema(db);
  const limit = Math.max(1, Math.min(500, Number(options.limit || 100)));
  const sinceMs = Number(options.sinceMs || 0);
  const rows = sinceMs > 0
    ? db.prepare('SELECT * FROM ctm_auth_audit WHERE created_at >= ? ORDER BY created_at DESC LIMIT ?').all(sinceMs, limit)
    : db.prepare('SELECT * FROM ctm_auth_audit ORDER BY created_at DESC LIMIT ?').all(limit);
  return rows.map((row) => {
    let details = null;
    try { details = row.details ? JSON.parse(row.details) : null; } catch { details = null; }
    return { ...row, details };
  });
}

module.exports = {
  cancelDeviceClaim,
  deviceTokenAbsoluteMaxMs,
  deviceTokenTtlMs,
  cleanupExpiredDeviceClaims,
  cleanupExpiredPairingRequests,
  cleanupExpiredWebAuthnChallenges,
  cleanupMobileAuthArtifacts,
  approvePairingRequest,
  createPairingRequest,
  createStepUpSession,
  createDeviceClaim,
  ensureMobileAuthSchema,
  ensurePairingRequestClaim,
  finishDeviceClaim,
  getCredentialForDevice,
  getClaimRecoveryCredential,
  getDeviceToken,
  getWebAuthnChallenge,
  hashValue,
  insertAudit,
  listAuthAudit,
  listDeviceTokens,
  listDeviceTokenDetails,
  listActiveDeviceProfiles,
  listClaimRecoveryCredentials,
  listPairingRequests,
  listCredentialsForDevice,
  normalizeScopes,
  parseScopes,
  registerWebAuthnCredential,
  resolveDeviceToken,
  rejectPairingRequest,
  recoverDeviceClaim,
  revokeAllDeviceTokens,
  revokeDuplicateDeviceTokens,
  revokeDeviceToken,
  saveWebAuthnChallenge,
  consumeWebAuthnChallenge,
  updateCredentialCounter,
  updateDeviceClaim,
  updateDeviceClaimScopes,
  updateDeviceToken,
  validateStepUpSession,
  verifyDeviceClaimSecret,
  verifyPairingRequestSecret,
};
