/**
 * lib/invites.js — Org invite domain layer (local source of truth).
 *
 * ONE table, BOTH invite kinds (eng-review decision 1):
 *   • email pre-invite  — `email` set; claimed on first login by verified-email
 *     match via claimEmailInvites(). No placeholder users row, no pending
 *     membership: a real membership row is created only on an actual join.
 *   • shareable link    — `token_hash` set; redeemed via redeemInvite(token,…).
 *
 * SECURITY (mirrors the cloud SECURITY DEFINER RPCs in 0003_org_invites.sql):
 *   H1  the raw link token is a SECRET. It is a >=128-bit CSPRNG value, returned
 *       to the creator ONCE, and only its SHA-256 hash is persisted. The raw
 *       token is never stored, logged, or audited.
 *   H4  the use-count bump is a single conditional UPDATE (no check-then-write).
 *       Membership insert is idempotent (addMember upserts on org+user). A
 *       re-redeem by an existing member is a no-op that consumes no use (Q2).
 *   H5  the granted role is bounded by the CREATOR's role: an owner may mint any
 *       role; an admin may mint member/viewer only.
 *
 * This module is local-first: it is the source of truth for the owner's machine
 * and the local mirror on member machines. The cloud path (members redeeming via
 * their own JWT against the RPCs) is wired in the API routes and is independently
 * integration-verified — see supabase/migrations/0003_org_invites.sql.
 */

import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { getDb, nowIso, newId } from './db.js';
import { addMember, getMembership } from './org.js';
import {
  getAuthMode, setAuthConfig, ensureRecoveryToken,
  getSupabaseServiceKey, testSupabaseConnection,
} from './auth-config.js';

async function _audit(entry) {
  try {
    const { appendAudit } = await import('./runtime.js');
    appendAudit(entry);
  } catch {
    // Non-fatal — audit failure must never block the primary operation.
  }
}

// ── Constants ───────────────────────────────────────────────────────────────────

export const DEFAULT_INVITE_TTL_HOURS = 72; // H1: short TTL by default.
const TOKEN_BYTES = 32; // 256-bit CSPRNG (>= the 128-bit H1 floor).
const VALID_ROLES = Object.freeze(['owner', 'admin', 'member', 'viewer']);

// H5: which roles a creator of a given role may grant via an invite.
const ROLE_CEILING = Object.freeze({
  owner: ['owner', 'admin', 'member', 'viewer'],
  admin: ['member', 'viewer'],
  member: [],
  viewer: [],
});

// ── Pure helpers ─────────────────────────────────────────────────────────────────

/** Normalizes an email for matching: trim + lowercase. */
export function normalizeEmail(email) {
  return String(email || '').trim().toLowerCase();
}

/** SHA-256 hex of a raw token. The only token representation ever persisted. */
export function hashToken(rawToken) {
  return createHash('sha256').update(String(rawToken), 'utf8').digest('hex');
}

/** Generates a fresh CSPRNG invite token (url-safe, >=128-bit). */
export function generateToken() {
  return randomBytes(TOKEN_BYTES).toString('base64url');
}

/** Throws unless `creatorRole` is permitted to mint an invite granting `grantedRole` (H5). */
export function assertRoleCeiling(creatorRole, grantedRole) {
  if (!VALID_ROLES.includes(grantedRole)) {
    throw new Error(`Invalid role: ${grantedRole}`);
  }
  const allowed = ROLE_CEILING[creatorRole] || [];
  if (!allowed.includes(grantedRole)) {
    throw Object.assign(new Error(`role ${creatorRole} cannot mint an invite granting ${grantedRole}`), {
      code: 'role_ceiling',
    });
  }
}

function _hoursFromNowIso(hours) {
  return new Date(Date.now() + hours * 3600 * 1000).toISOString();
}

function _isExpired(row, nowMs = Date.now()) {
  return row.expires_at != null && new Date(row.expires_at).getTime() <= nowMs;
}

function _isMaxedOut(row) {
  return row.max_uses != null && row.used_count >= row.max_uses;
}

// ── Create ────────────────────────────────────────────────────────────────────────

/**
 * Creates an email pre-invite. Idempotent per (org, email): an existing, still
 * pending invite for the same email is returned rather than duplicated.
 *
 * @param {{ orgId, email, role?, createdBy, creatorRole, label? }} args
 * @returns {object} the invite row.
 */
export function createEmailInvite({ orgId, email, role = 'member', createdBy, creatorRole, label = null }) {
  if (!orgId || !email || !createdBy) throw new Error('orgId, email, createdBy are required');
  assertRoleCeiling(creatorRole, role);
  const normalized = normalizeEmail(email);
  if (!normalized.includes('@')) throw new Error('a valid email is required');

  const db = getDb();
  const existing = db
    .prepare('SELECT * FROM org_invites WHERE org_id = ? AND email = ? AND revoked_at IS NULL')
    .get(orgId, normalized);
  if (existing && !_isExpired(existing)) {
    // Refresh the granted role/expiry on re-invite rather than create a duplicate.
    db.prepare('UPDATE org_invites SET role = ?, expires_at = ? WHERE id = ?')
      .run(role, _hoursFromNowIso(DEFAULT_INVITE_TTL_HOURS), existing.id);
    return db.prepare('SELECT * FROM org_invites WHERE id = ?').get(existing.id);
  }

  const id = newId('inv');
  db.prepare(
    `INSERT INTO org_invites (id, org_id, role, email, label, created_by_user_id, expires_at, created_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
  ).run(id, orgId, role, normalized, label, createdBy, _hoursFromNowIso(DEFAULT_INVITE_TTL_HOURS), nowIso());
  _audit({
    type: 'org.invite.email.created',
    summary: `Email invite for ${normalized} to org ${orgId} as ${role}`,
    meta: { id, org_id: orgId, email: normalized, role },
  });
  return db.prepare('SELECT * FROM org_invites WHERE id = ?').get(id);
}

/**
 * Creates a shareable-link invite. Returns the raw token ONCE (H1): only its hash
 * is persisted. The caller must surface the token immediately and never store it.
 *
 * @param {{ orgId, role?, createdBy, creatorRole, expiresInHours?, maxUses?, label? }} args
 * @returns {{ id, token, invite }} raw token + the stored row (no token_hash leaked downstream).
 */
export function createInvite({
  orgId, role = 'member', createdBy, creatorRole,
  expiresInHours = DEFAULT_INVITE_TTL_HOURS, maxUses = null, label = null,
}) {
  if (!orgId || !createdBy) throw new Error('orgId and createdBy are required');
  assertRoleCeiling(creatorRole, role);
  if (maxUses != null && (!Number.isInteger(maxUses) || maxUses < 1)) {
    throw new Error('maxUses must be a positive integer or null');
  }
  // expiresInHours null => no expiry; otherwise must be positive.
  const expiresAt = expiresInHours == null ? null : _hoursFromNowIso(expiresInHours);

  const token = generateToken();
  const id = newId('inv');
  getDb().prepare(
    `INSERT INTO org_invites (id, org_id, role, token_hash, label, created_by_user_id, expires_at, max_uses, created_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
  ).run(id, orgId, role, hashToken(token), label, createdBy, expiresAt, maxUses, nowIso());
  _audit({
    type: 'org.invite.created',
    summary: `Invite link created for org ${orgId} as ${role}`,
    meta: { id, org_id: orgId, role, max_uses: maxUses, expires_at: expiresAt }, // no token
  });
  const invite = getDb().prepare('SELECT * FROM org_invites WHERE id = ?').get(id);
  return { id, token, invite: _sanitize(invite) };
}

// ── Lookup ──────────────────────────────────────────────────────────────────────

/** Strips the token_hash from a row before it leaves the module. */
function _sanitize(row) {
  if (!row) return row;
  const { token_hash, ...rest } = row;
  return { ...rest, has_token: token_hash != null };
}

/**
 * Resolves a link invite by its raw token, validating revoke/expiry/max-uses.
 * Returns the row (sanitized) or null. The hash lookup is exact-match; a
 * timing-safe compare guards the final equality so token validation does not
 * leak via comparison timing.
 */
export function getInviteByToken(token) {
  if (!token) return null;
  const hash = hashToken(token);
  const row = getDb().prepare('SELECT * FROM org_invites WHERE token_hash = ?').get(hash);
  if (!row) return null;
  // Defensive constant-time confirmation of the hash match.
  const a = Buffer.from(hash, 'hex');
  const b = Buffer.from(row.token_hash, 'hex');
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  if (row.revoked_at != null) return null;
  if (_isExpired(row)) return null;
  if (_isMaxedOut(row)) return null;
  return _sanitize(row);
}

/** Lists invites for an org (admin surface). Token hashes are never returned. */
export function listInvites(orgId, { includeInactive = false } = {}) {
  const rows = getDb()
    .prepare('SELECT * FROM org_invites WHERE org_id = ? ORDER BY created_at DESC')
    .all(orgId);
  const out = rows
    .filter((r) => includeInactive || (r.revoked_at == null && !_isExpired(r) && !_isMaxedOut(r)))
    .map((r) => ({ ..._sanitize(r), kind: r.email ? 'email' : 'link' }));
  return out;
}

/** Returns pending (active) email invites for a normalized email — across orgs. */
export function listPendingEmailInvitesForEmail(email) {
  const normalized = normalizeEmail(email);
  if (!normalized) return [];
  const rows = getDb()
    .prepare(
      `SELECT i.*, o.name AS org_name, o.slug AS org_slug
       FROM org_invites i JOIN organizations o ON o.id = i.org_id
       WHERE i.email = ? AND i.revoked_at IS NULL`
    )
    .all(normalized);
  return rows.filter((r) => !_isExpired(r)).map((r) => ({
    id: r.id, org_id: r.org_id, org_name: r.org_name, org_slug: r.org_slug,
    role: r.role, created_at: r.created_at, expires_at: r.expires_at,
  }));
}

// ── Redeem / claim ────────────────────────────────────────────────────────────────

/**
 * Redeems a shareable-link token for a signed-in user. Atomic (H4): the use-count
 * bump is a single conditional UPDATE. A re-redeem by an existing member is a
 * no-op that consumes no use (Q2). Returns { org_id, role, already_member }.
 */
export function redeemInvite(token, userId) {
  if (!token || !userId) throw new Error('token and userId are required');
  const hash = hashToken(token);
  const db = getDb();

  // Look up first (without trusting the row for validity — the conditional
  // UPDATE below is the real gate).
  const row = db.prepare('SELECT * FROM org_invites WHERE token_hash = ?').get(hash);
  if (!row || row.email != null) {
    throw Object.assign(new Error('invalid invite token'), { code: 'invalid_token' });
  }

  // Q2: a user who is ALREADY a member just resolves to their org — no use spent.
  const existingMembership = getMembership(userId, row.org_id);
  if (existingMembership) {
    return { org_id: row.org_id, role: existingMembership.role, already_member: true };
  }

  db.exec('BEGIN IMMEDIATE');
  try {
    const res = db
      .prepare(
        `UPDATE org_invites SET used_count = used_count + 1
         WHERE id = ? AND revoked_at IS NULL
           AND (expires_at IS NULL OR expires_at > ?)
           AND (max_uses IS NULL OR used_count < max_uses)`
      )
      .run(row.id, nowIso());
    if (res.changes !== 1) {
      db.exec('ROLLBACK');
      throw Object.assign(new Error('invite is no longer valid (expired, revoked, or fully used)'), {
        code: 'invite_invalid',
      });
    }
    addMember(row.org_id, userId, row.role);
    db.exec('COMMIT');
  } catch (err) {
    try { db.exec('ROLLBACK'); } catch { /* already rolled back */ }
    throw err;
  }

  _audit({
    type: 'org.invite.redeemed',
    summary: `User ${userId} redeemed an invite to org ${row.org_id} as ${row.role}`,
    meta: { invite_id: row.id, org_id: row.org_id, user_id: userId, role: row.role },
  });
  return { org_id: row.org_id, role: row.role, already_member: false };
}

/**
 * Claims any pending EMAIL invites for a signing-in user (matched by normalized
 * email). Creates the membership in the inviting org(s). Idempotent: re-running
 * for an already-claimed invite is a no-op (addMember upserts; the email invite
 * is marked claimed by setting revoked_at once consumed).
 *
 * @returns {Array<{ org_id, role }>} the orgs the user is now a member of via claim.
 */
export function claimEmailInvites(userId, email) {
  const normalized = normalizeEmail(email);
  if (!userId || !normalized) return [];
  const db = getDb();
  const invites = db
    .prepare('SELECT * FROM org_invites WHERE email = ? AND revoked_at IS NULL')
    .all(normalized)
    .filter((r) => !_isExpired(r));

  const claimed = [];
  for (const inv of invites) {
    db.exec('BEGIN IMMEDIATE');
    try {
      addMember(inv.org_id, userId, inv.role);
      // Mark the email invite consumed so it cannot be re-claimed by a different
      // (squatted) account later. used_count bump + revoke.
      db.prepare('UPDATE org_invites SET used_count = used_count + 1, revoked_at = ? WHERE id = ?')
        .run(nowIso(), inv.id);
      db.exec('COMMIT');
    } catch (err) {
      try { db.exec('ROLLBACK'); } catch { /* noop */ }
      // One bad invite must not block the rest.
      continue;
    }
    claimed.push({ org_id: inv.org_id, role: inv.role });
    _audit({
      type: 'org.invite.email.claimed',
      summary: `User ${userId} claimed email invite to org ${inv.org_id} as ${inv.role}`,
      meta: { invite_id: inv.id, org_id: inv.org_id, user_id: userId, role: inv.role },
    });
  }
  return claimed;
}

// ── Revoke ──────────────────────────────────────────────────────────────────────

/** Revokes an invite by id (admin action). Idempotent. */
export function revokeInvite(inviteId, byUserId) {
  const db = getDb();
  const row = db.prepare('SELECT * FROM org_invites WHERE id = ?').get(inviteId);
  if (!row) return null;
  if (row.revoked_at == null) {
    db.prepare('UPDATE org_invites SET revoked_at = ? WHERE id = ?').run(nowIso(), inviteId);
    _audit({
      type: 'org.invite.revoked',
      summary: `Invite ${inviteId} revoked in org ${row.org_id}`,
      meta: { invite_id: inviteId, org_id: row.org_id, by: byUserId },
    });
  }
  return getDb().prepare('SELECT org_id, revoked_at FROM org_invites WHERE id = ?').get(inviteId);
}

// ════════════════════════════════════════════════════════════════════════════
//  PHASE 4 — the invite BLOB + join-bootstrap (security-sensitive)
//
//  A member pastes ONE invite link and ends up connected to the shared project,
//  signed in, and joined — having never seen the service-role key. The blob is a
//  NON-SECRET payload (url + anon key are public) EXCEPT the embedded token,
//  which is a bearer credential (H1) and must ride in the link FRAGMENT or a POST
//  body, never a query string.
// ════════════════════════════════════════════════════════════════════════════

const MAX_BLOB_BYTES = 4096;
// Any field whose name looks like a service-role key is rejected outright (H6/H1):
// the blob must never carry, and join-bootstrap must never persist, a service key.
const FORBIDDEN_SECRET_KEYS = ['servicekey', 'service_role', 'service_role_key', 'servicerolekey', 'supabase_service_role_key'];

function _hasForbiddenSecretKey(obj) {
  return Object.keys(obj || {}).some((k) => FORBIDDEN_SECRET_KEYS.includes(String(k).toLowerCase()));
}

/** Encodes a non-secret invite payload as base64url. The token is a bearer secret (H1). */
export function encodeInviteBlob({ url, anonKey, token, org } = {}) {
  if (!url || !anonKey || !token) throw new Error('url, anonKey, token are required');
  const payload = { v: 1, url, anonKey, token };
  if (org) payload.org = org;
  return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
}

/**
 * Decodes + validates an invite blob. Rejects: oversized, non-object, wrong
 * version, non-https url, missing anonKey/token, and ANY field that looks like a
 * service-role key. Returns ONLY { v, url, anonKey, token, org } — nothing else
 * can flow downstream even if the attacker stuffed extra fields.
 */
export function decodeInviteBlob(blob) {
  if (typeof blob !== 'string' || !blob) {
    throw Object.assign(new Error('this invite link is invalid or has expired'), { code: 'bad_blob' });
  }
  if (blob.length > MAX_BLOB_BYTES) {
    throw Object.assign(new Error('invite link is too large'), { code: 'bad_blob' });
  }
  let json;
  try {
    const raw = Buffer.from(blob, 'base64url').toString('utf8');
    if (raw.length > MAX_BLOB_BYTES) throw new Error('oversized');
    json = JSON.parse(raw);
  } catch {
    throw Object.assign(new Error('this invite link is invalid or has expired'), { code: 'bad_blob' });
  }
  if (!json || typeof json !== 'object' || Array.isArray(json)) {
    throw Object.assign(new Error('this invite link is invalid or has expired'), { code: 'bad_blob' });
  }
  if (_hasForbiddenSecretKey(json)) {
    throw Object.assign(new Error('invite must not contain a service-role key'), { code: 'service_key_forbidden' });
  }
  if (json.v !== 1) {
    throw Object.assign(new Error('unsupported invite version'), { code: 'bad_blob' });
  }
  if (typeof json.url !== 'string' || !/^https:\/\//i.test(json.url)) {
    throw Object.assign(new Error('invite project URL must be https'), { code: 'bad_url' });
  }
  if (typeof json.anonKey !== 'string' || !json.anonKey.trim()) {
    throw Object.assign(new Error('invite is missing the project key'), { code: 'bad_blob' });
  }
  if (typeof json.token !== 'string' || !json.token.trim()) {
    throw Object.assign(new Error('invite is missing its token'), { code: 'missing_token' });
  }
  return {
    v: 1,
    url: json.url.trim(),
    anonKey: json.anonKey.trim(),
    token: json.token.trim(),
    org: typeof json.org === 'string' ? json.org : null,
  };
}

/**
 * Connects a fresh LOCAL install to the invite's Supabase project. Hard rules:
 *   • Only from local mode AND with no owner service key present — an attacker
 *     cannot re-point a live supabase / owner install (H6).
 *   • SSRF-guarded connection test runs BEFORE any config write.
 *   • Writes ONLY url + anon key. The service key is never accepted, and NO
 *     existing local secret is migrated into the new project config (H6b).
 *   • Keeps the local recovery hatch so a member can revert.
 *
 * @param {{ url, anonKey, token }} args
 * @param {{ testConnection?: Function }} [opts]  Test seam for the SSRF/health check.
 * @returns {Promise<{ ok: true, host: string }>}
 */
export async function joinBootstrap({ url, anonKey, token } = {}, { testConnection } = {}) {
  if (!url || !anonKey || !token) {
    throw Object.assign(new Error('url, anonKey, and token are required'), { code: 'bad_request' });
  }
  if (!/^https:\/\//i.test(url)) {
    throw Object.assign(new Error('project URL must be https'), { code: 'bad_url' });
  }
  // H6 + re-point guard: refuse on anything but a clean local install.
  if (getAuthMode() !== 'local' || getSupabaseServiceKey()) {
    throw Object.assign(new Error('this app is already connected to an organization'), { code: 'already_supabase' });
  }

  const tester = testConnection || testSupabaseConnection;
  const res = await tester({ url, anonKey });
  if (!res?.ok) {
    throw Object.assign(new Error("couldn't reach that project"), { code: 'connection_failed', detail: res?.error });
  }

  // ONLY url + anon are written. setAuthConfig ignores an absent serviceKey, and
  // we deliberately never read/copy an existing local secret into the new config.
  setAuthConfig({ mode: 'supabase', url, anonKey });
  ensureRecoveryToken();

  const host = new URL(url).host;
  _audit({
    type: 'auth.join.bootstrap',
    summary: `Connected local app to project ${host} via invite bootstrap`,
    meta: { host }, // never the token or any key
  });
  return { ok: true, host };
}
