const bcrypt = require('bcryptjs');
const { all, audit, now, run } = require('../db');

// Configurable password & lockout policy for locally managed users. The policy lives as
// `password_*` rows in app_settings (no schema churn to tune rules), is read back through
// getPolicy(), and is enforced at every password-set point plus on login.

const DEFAULT_POLICY = {
  min_length: 12,
  require_upper: true,
  require_lower: true,
  require_number: true,
  require_symbol: true,
  disallow_identifiers: true, // block the user's name / email local-part inside the password
  history_count: 5, // disallow reuse of the last N passwords (0 = off)
  expiry_days: 90, // force a change after this many days (0 = never)
  min_age_hours: 0, // minimum age before a user may change again (0 = off)
  lockout_threshold: 5, // failed logins before lock (0 = off)
  lockout_window_minutes: 15 // how long an account stays locked
};

// Each policy field plus how to coerce the stored string back to its typed value.
const NUMERIC_KEYS = ['min_length', 'history_count', 'expiry_days', 'min_age_hours', 'lockout_threshold', 'lockout_window_minutes'];
const BOOLEAN_KEYS = ['require_upper', 'require_lower', 'require_number', 'require_symbol', 'disallow_identifiers'];

function clampInt(value, fallback, min, max) {
  const n = Number(value);
  if (!Number.isFinite(n)) return fallback;
  return Math.min(max, Math.max(min, Math.round(n)));
}

function toBool(value, fallback) {
  if (value === undefined || value === null || value === '') return fallback;
  if (typeof value === 'boolean') return value;
  return value === '1' || value === 'true' || value === 1;
}

// Read the saved policy, falling back to defaults for any unset key.
function getPolicy() {
  const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'password_%'");
  const stored = Object.fromEntries(rows.map((r) => [r.key.replace(/^password_/, ''), r.value]));
  const policy = { ...DEFAULT_POLICY };
  for (const key of NUMERIC_KEYS) {
    if (stored[key] !== undefined) policy[key] = clampInt(stored[key], DEFAULT_POLICY[key], 0, 100000);
  }
  for (const key of BOOLEAN_KEYS) {
    if (stored[key] !== undefined) policy[key] = toBool(stored[key], DEFAULT_POLICY[key]);
  }
  // min_length has a sane floor regardless of what was stored.
  policy.min_length = Math.max(4, policy.min_length);
  return policy;
}

// Persist a (partial) policy. Unknown keys are ignored; values are normalized through getPolicy's coercion.
function savePolicy(body, userId) {
  const before = getPolicy();
  const incoming = body && typeof body === 'object' ? body : {};
  const ts = now();
  const write = (key, value) => run(
    'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
    [`password_${key}`, String(value), ts]
  );
  for (const key of NUMERIC_KEYS) {
    if (incoming[key] !== undefined) write(key, clampInt(incoming[key], DEFAULT_POLICY[key], 0, 100000));
  }
  for (const key of BOOLEAN_KEYS) {
    if (incoming[key] !== undefined) write(key, toBool(incoming[key], DEFAULT_POLICY[key]) ? 1 : 0);
  }
  const after = getPolicy();
  audit(userId, 'update', 'password_policy', 'app', before, after);
  return after;
}

// Human-readable rules for the UI hint/preview.
function describe(policy = getPolicy()) {
  const rules = [`At least ${policy.min_length} characters`];
  if (policy.require_upper) rules.push('An uppercase letter (A–Z)');
  if (policy.require_lower) rules.push('A lowercase letter (a–z)');
  if (policy.require_number) rules.push('A number (0–9)');
  if (policy.require_symbol) rules.push('A symbol (e.g. !@#$%)');
  if (policy.disallow_identifiers) rules.push('Must not contain your name or email');
  if (policy.history_count > 0) rules.push(`Cannot reuse your last ${policy.history_count} password(s)`);
  if (policy.expiry_days > 0) rules.push(`Expires every ${policy.expiry_days} days`);
  if (policy.lockout_threshold > 0) rules.push(`Locks for ${policy.lockout_window_minutes} min after ${policy.lockout_threshold} failed sign-ins`);
  return rules;
}

const SYMBOL_RE = /[^A-Za-z0-9]/;

// Returns an array of human-readable failure messages ([] when the password satisfies the policy).
// `user` (optional) enables identifier and history checks; pass { id, name, email }.
function checkPassword(password, user = null, policy = getPolicy()) {
  const failures = [];
  const value = String(password || '');
  if (value.length < policy.min_length) failures.push(`Use at least ${policy.min_length} characters`);
  if (policy.require_upper && !/[A-Z]/.test(value)) failures.push('Add an uppercase letter');
  if (policy.require_lower && !/[a-z]/.test(value)) failures.push('Add a lowercase letter');
  if (policy.require_number && !/[0-9]/.test(value)) failures.push('Add a number');
  if (policy.require_symbol && !SYMBOL_RE.test(value)) failures.push('Add a symbol');

  if (policy.disallow_identifiers && user) {
    const haystack = value.toLowerCase();
    const needles = [];
    if (user.name) needles.push(...String(user.name).toLowerCase().split(/\s+/).filter((p) => p.length >= 3));
    if (user.email) {
      const local = String(user.email).toLowerCase().split('@')[0];
      if (local && local.length >= 3) needles.push(local);
    }
    if (needles.some((n) => haystack.includes(n))) failures.push('Must not contain your name or email');
  }

  if (policy.history_count > 0 && user && user.id) {
    const recent = all(
      'SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY id DESC LIMIT ?',
      [user.id, policy.history_count]
    );
    if (recent.some((row) => bcrypt.compareSync(value, row.password_hash))) {
      failures.push(`Choose a password you have not used in your last ${policy.history_count}`);
    }
  }
  return failures;
}

// Throwing wrapper used by routes: 400s with the joined failure list.
function validatePassword(password, user = null) {
  const failures = checkPassword(password, user, getPolicy());
  if (failures.length) throw Object.assign(new Error(failures.join('. ')), { status: 400 });
}

// Record a newly set hash and prune the per-user history to the configured depth.
function recordPassword(userId, hash) {
  run('INSERT INTO password_history (user_id, password_hash, created_at) VALUES (?, ?, ?)', [userId, hash, now()]);
  const keep = Math.max(getPolicy().history_count, 1);
  run(
    `DELETE FROM password_history
     WHERE user_id = ?
       AND id NOT IN (SELECT id FROM password_history WHERE user_id = ? ORDER BY id DESC LIMIT ?)`,
    [userId, userId, keep]
  );
}

// ---- Account lockout ---------------------------------------------------------

function isLocked(user) {
  return Boolean(user && user.locked_until && new Date(user.locked_until).getTime() > Date.now());
}

// After a failed login: bump the counter and lock the account if the threshold is reached.
// Returns { locked, lockedUntil } so the caller can shape the response.
function registerFailure(user) {
  const policy = getPolicy();
  const attempts = (user.failed_attempts || 0) + 1;
  let lockedUntil = null;
  if (policy.lockout_threshold > 0 && attempts >= policy.lockout_threshold) {
    lockedUntil = new Date(Date.now() + policy.lockout_window_minutes * 60000).toISOString();
  }
  run('UPDATE users SET failed_attempts = ?, locked_until = ?, updated_at = ? WHERE id = ?', [
    attempts, lockedUntil, now(), user.id
  ]);
  if (lockedUntil) audit(user.id, 'account_locked', 'user', user.id, null, { until: lockedUntil, attempts });
  return { locked: Boolean(lockedUntil), lockedUntil, attempts };
}

// After a successful login: clear the lockout counters and stamp last_login_at.
function registerSuccess(user) {
  run('UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE id = ?', [
    now(), now(), user.id
  ]);
}

// Admin action: clear a lock so the user can sign in again immediately.
function unlock(userId, actorId) {
  run('UPDATE users SET failed_attempts = 0, locked_until = NULL, updated_at = ? WHERE id = ?', [now(), userId]);
  audit(actorId, 'account_unlocked', 'user', userId, null, null);
}

// Has this user's password aged past the expiry window?
function passwordExpired(user, policy = getPolicy()) {
  if (!policy.expiry_days || !user || !user.password_changed_at) return false;
  const age = Date.now() - new Date(user.password_changed_at).getTime();
  return age > policy.expiry_days * 86400000;
}

// Does the policy forbid changing again this soon? Returns minutes remaining (0 when allowed).
function minAgeRemainingMinutes(user, policy = getPolicy()) {
  if (!policy.min_age_hours || !user || !user.password_changed_at) return 0;
  const elapsedMs = Date.now() - new Date(user.password_changed_at).getTime();
  const remainingMs = policy.min_age_hours * 3600000 - elapsedMs;
  return remainingMs > 0 ? Math.ceil(remainingMs / 60000) : 0;
}

// Convenience used by login to decide whether to force a change step.
function mustChangePassword(user) {
  return Boolean(user.must_change_password) || passwordExpired(user);
}

module.exports = {
  DEFAULT_POLICY,
  checkPassword,
  describe,
  getPolicy,
  isLocked,
  minAgeRemainingMinutes,
  mustChangePassword,
  passwordExpired,
  recordPassword,
  registerFailure,
  registerSuccess,
  savePolicy,
  unlock,
  validatePassword
};
