/**
 * rbac.js — Role-Based Access Control helpers.
 *
 * Phase 1 PR 1 (2026-07-22) — auxiliary roles thin slice.
 *
 * The single-role model (users.role) is too narrow for the school. We need:
 *   - app-wide auxiliary roles (a teacher who also runs the library)
 *   - time-scoped covers (a sports coach covering a sick teacher's class)
 *   - resource-scoped grants (a teacher leading a particular cohort)
 *
 * This module centralises the rules so every controller asks the same
 * question the same way. Existing controllers that still use
 * `req.user.role !== 'admin'` keep working — they just don't see auxiliary
 * roles. New code that needs the full picture calls `hasRole` /
 * `hasRoleForClass` / `requireRole` / `requireRoleForClass` from here.
 *
 * The JWT carries an `effective_roles` array (primary + active grants).
 * The helpers below read from the JWT payload first and only hit the
 * database when the caller wants a live read (e.g. "who is currently
 * covering class X?").
 *
 * Hot path:
 *   - hasRole(user, role)            — O(1) string check on the JWT payload
 *   - hasRoleForClass(user, c, role) — O(1) string check + a small map lookup
 *   - getActiveCovers(classId)       — one SQL query, used by the class detail UI
 */

'use strict';

const Database = require('better-sqlite3');
const path = require('path');

const dbPath = process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'school.db');
let db;
const getDb = () => {
  if (!db) {
    db = new Database(dbPath);
    db.pragma('foreign_keys = ON');
  }
  return db;
};

/**
 * Compute the effective roles for a user.
 *
 * Reads the user_roles table for non-revoked, non-expired, non-deleted
 * grants and returns the union of {users.role} and those grants. Used:
 *   - on login, to embed `effective_roles` in the JWT payload
 *   - on /api/auth/refresh-roles, to re-sign the JWT after a grant mutation
 *
 * @param {number} userId
 * @returns {string[]} sorted, de-duplicated list of role names
 */
function getEffectiveRolesForUser(userId) {
  const row = getDb()
    .prepare('SELECT role FROM users WHERE id = ? AND is_deleted = 0')
    .get(userId);
  if (!row) return [];
  const grants = getDb()
    .prepare(
      `SELECT DISTINCT role FROM user_roles
         WHERE user_id = ?
           AND is_deleted = 0
           AND revoked_at IS NULL
           AND (starts_at IS NULL OR datetime(starts_at) <= datetime('now','localtime'))
           AND (expires_at IS NULL OR datetime(expires_at) > datetime('now','localtime'))`
    )
    .all(userId);
  const set = new Set([row.role, ...grants.map((g) => g.role)]);
  return Array.from(set).sort();
}

/**
 * Effective roles from a JWT payload (already computed at login time).
 * Falls back to the primary role if the payload is old (no effective_roles).
 */
function getEffectiveRoles(user) {
  if (!user) return [];
  if (Array.isArray(user.effective_roles) && user.effective_roles.length > 0) {
    return user.effective_roles;
  }
  return user.role ? [user.role] : [];
}

/**
 * Does this user have role X right now?
 * O(1) — no DB hit.
 */
function hasRole(user, role) {
  if (!user || !role) return false;
  if (user.role === role) return true;
  if (Array.isArray(user.effective_roles)) {
    return user.effective_roles.includes(role);
  }
  return false;
}

/**
 * Does this user have role X for class Y right now?
 *
 * Three sources of truth, in order:
 *   1. Primary role: if the user's primary role is `role` AND they're a class
 *      teacher of class Y (classes.class_teacher_id = userId) — yes.
 *   2. Unscoped auxiliary: if the user has `role` as an unscoped grant — yes.
 *   3. Class-scoped grant: if the user has an active `user_roles` row with
 *      scope_class_id = Y and role = `role` — yes.
 *
 * Live DB query for cases 1 and 3 (case 2 is a JWT lookup). Use sparingly
 * (per-request) and prefer caching the result on `req` if you call this
 * multiple times in the same request.
 */
function hasRoleForClass(user, classId, role) {
  if (!user || !classId || !role) return false;
  // Case 1 + 2: primary role is `role` (covers admin override too) OR
  // an unscoped grant for `role` exists in the JWT.
  if (hasRole(user, role) && !isClassScopedOnly(user, role, classId)) {
    // Even with the role, the user needs to be connected to this class
    // (form tutor, or an unscoped grant). Admins get a free pass.
    if (hasRole(user, 'school_admin') || hasRole(user, 'systems_admin')) return true;
    if (isUnscopedGrant(user, role)) return true;
    if (isClassTeacher(user.id, classId)) return true;
  }
  // Case 3: class-scoped grant from a live DB read.
  return hasClassScopedGrant(user.id, classId, role);
}

function isUnscopedGrant(user, role) {
  // We don't carry the scope shape in the JWT, only the role list. So an
  // unscoped grant is any auxiliary grant for `role` in user_roles. We
  // resolve this from the DB on demand (called rarely — once per request
  // by the cover-aware routes).
  if (user.role === role) return false; // primary role, not a grant
  const row = getDb()
    .prepare(
      `SELECT 1 FROM user_roles
         WHERE user_id = ?
           AND role = ?
           AND is_deleted = 0
           AND revoked_at IS NULL
           AND scope_class_id IS NULL
           AND scope_subject_id IS NULL
           AND scope_cohort_id IS NULL
           AND (expires_at IS NULL OR datetime(expires_at) > datetime('now','localtime'))
         LIMIT 1`
    )
    .get(user.id, role);
  return !!row;
}

function isClassScopedOnly(user, role, classId) {
  // True if the user has `role` ONLY via a class-scoped grant on classId
  // (and not via the primary role or any other grant). Used internally to
  // decide whether to fall through to the class-scoped grant check.
  if (user.role === role) return false;
  const row = getDb()
    .prepare(
      `SELECT 1 FROM user_roles
         WHERE user_id = ?
           AND role = ?
           AND is_deleted = 0
           AND revoked_at IS NULL
           AND scope_class_id = ?
           AND (expires_at IS NULL OR datetime(expires_at) > datetime('now','localtime'))
         LIMIT 1`
    )
    .get(user.id, role, classId);
  return !!row;
}

function isClassTeacher(userId, classId) {
  const row = getDb()
    .prepare('SELECT 1 FROM classes WHERE id = ? AND class_teacher_id = ?')
    .get(classId, userId);
  return !!row;
}

function hasClassScopedGrant(userId, classId, role) {
  const row = getDb()
    .prepare(
      `SELECT 1 FROM user_roles
         WHERE user_id = ?
           AND role = ?
           AND scope_class_id = ?
           AND is_deleted = 0
           AND revoked_at IS NULL
           AND (starts_at IS NULL OR datetime(starts_at) <= datetime('now','localtime'))
           AND (expires_at IS NULL OR datetime(expires_at) > datetime('now','localtime'))
         LIMIT 1`
    )
    .get(userId, role, classId);
  return !!row;
}

/**
 * "Who is currently covering class X?" — used by the class detail UI to
 * render the "currently covering" indicator. Returns one row per active
 * cover grant; the caller is responsible for joining with users to get
 * the cover teacher's name.
 */
function getActiveCovers(classId, asOfDate) {
  const date = asOfDate || new Date().toISOString().slice(0, 10);
  return getDb()
    .prepare(
      `SELECT ur.id, ur.user_id, ur.role, ur.scope_class_id, ur.scope_subject_id,
              ur.starts_at, ur.expires_at, ur.reason, ur.granted_by, ur.granted_at,
              u.first_name, u.last_name, u.email
         FROM user_roles ur
         JOIN users u ON u.id = ur.user_id
         WHERE ur.scope_class_id = ?
           AND ur.is_deleted = 0
           AND ur.revoked_at IS NULL
           AND (ur.starts_at IS NULL OR datetime(ur.starts_at) <= datetime('now','localtime'))
           AND (ur.expires_at IS NULL OR date(ur.expires_at) >= ?)
         ORDER BY ur.granted_at DESC`
    )
    .all(classId, date);
}

// ---- Express middleware factories -------------------------------------------

function requireRole(role) {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
    if (!hasRole(req.user, role)) {
      return res.status(403).json({ error: `Role '${role}' required` });
    }
    next();
  };
}

function requireRoleForClass(role, classIdFrom = 'params.classId') {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
    const classId = Number(getByPath(req, classIdFrom));
    if (!classId) {
      return res.status(400).json({ error: 'classId is required for this check' });
    }
    if (!hasRoleForClass(req.user, classId, role)) {
      return res.status(403).json({
        error: `Role '${role}' required for class ${classId}`,
      });
    }
    next();
  };
}

function getByPath(obj, path) {
  return path.split('.').reduce((acc, key) => (acc ? acc[key] : undefined), obj);
}

module.exports = {
  getEffectiveRolesForUser,
  getEffectiveRoles,
  hasRole,
  hasRoleForClass,
  getActiveCovers,
  requireRole,
  requireRoleForClass,
};
