/**
 * EmailMagicLink tokens store.
 *
 * Stores SHA-256-hashed tokens in a JSON file. The raw token NEVER lives on
 * disk — only its hash. This means a read-only disk compromise cannot be
 * used to forge login links.
 *
 * Schema (tokens file):
 *   {
 *     "byHash": {
 *       "<sha256-hex>": {
 *         "email":    "user@example.com",
 *         "expiresAt": 1721322000000,
 *         "issuedAt":  1721321100000,
 *         "usedAt":    null,
 *         "ip":        "10.0.0.1",
 *         "userAgent": "Mozilla/5.0 ..."
 *       },
 *       ...
 *     }
 *   }
 *
 * Concurrency: writes go through a single in-flight queue. The store never
 * loses tokens due to interleaved read-modify-write cycles. Reads are
 * unlocked and may see slightly stale data (acceptable — token TTL is 15min
 * so a stale read at worst surfaces an expired token that the next request
 * will catch).
 *
 * Garbage collection: expired-and-used tokens are pruned every PRUNE_INTERVAL
 * via `startPruneTimer()` (auto-started by `createStore()`). Tests that want
 * deterministic behavior can call `prune()` directly and skip the timer.
 */

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const TOKEN_TTL_MS = 15 * 60 * 1000;       // 15 minutes
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;  // hourly prune of used+expired
const MAX_TOKENS = 10000;                  // hard cap; protect the file

/**
 * Token-store factory. Captures the file path so callers don't have to
 * thread it through every method.
 *
 * @param {string} filePath  Absolute path to email-tokens.json
 * @returns {Object}         Token-store instance (see JSDoc below)
 */
function createStore(filePath) {
  if (typeof filePath !== 'string' || !filePath) {
    throw new Error('email-tokens-store: filePath required');
  }

  let writeQueue = Promise.resolve();
  let pruneTimer = null;

  function _readSync() {
    try {
      if (!fs.existsSync(filePath)) {
        return { byHash: {} };
      }
      const raw = fs.readFileSync(filePath, 'utf8');
      if (!raw.trim()) return { byHash: {} };
      const parsed = JSON.parse(raw);
      // Defensive: tolerate older shapes ({tokens: [...]}, flat object, etc).
      if (parsed && typeof parsed === 'object' && parsed.byHash && typeof parsed.byHash === 'object') {
        return parsed;
      }
      return { byHash: {} };
    } catch {
      // Treat unparseable file as empty — don't block login on a corrupt store.
      return { byHash: {} };
    }
  }

  function _writeSync(state) {
    const dir = path.dirname(filePath);
    try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
    // Atomic write: temp file + rename, so a crash mid-write doesn't corrupt.
    const tmp = filePath + '.tmp.' + process.pid;
    fs.writeFileSync(tmp, JSON.stringify(state));
    fs.renameSync(tmp, filePath);
  }

  function _enqueueWrite(mutator) {
    writeQueue = writeQueue.then(async () => {
      const state = _readSync();
      const result = await mutator(state);
      // Cap-store at MAX_TOKENS (drop oldest expired-then-recent ones first,
      // then oldest used if we still exceed). User-visible as "can't request
      // more links until old ones are cleaned up" — pathological case only.
      if (Object.keys(state.byHash).length > MAX_TOKENS) {
        _capStore(state);
      }
      _writeSync(state);
      return result;
    });
    return writeQueue;
  }

  function _capStore(state) {
    const entries = Object.entries(state.byHash);
    entries.sort((a, b) => (a[1].issuedAt || 0) - (b[1].issuedAt || 0));
    while (entries.length > MAX_TOKENS) {
      const [hash] = entries.shift();
      delete state.byHash[hash];
    }
  }

  /**
   * Issue a new token.
   *
   * @param {Object} meta  { email, ip, userAgent }
   * @returns {{ token: string, hash: string, expiresAt: number }}
   */
  function issue(meta) {
    const email = (meta && meta.email || '').toLowerCase().trim();
    const ip = (meta && meta.ip) || '';
    const userAgent = (meta && meta.userAgent) || '';
    const raw = crypto.randomBytes(32).toString('base64url');
    const hash = _hashToken(raw);
    const now = Date.now();
    const expiresAt = now + TOKEN_TTL_MS;
    const record = {
      email,
      issuedAt: now,
      expiresAt,
      usedAt: null,
      ip,
      userAgent,
    };
    // Issue is synchronous w.r.t. the in-memory state — the write happens
    // before `issue` resolves, so a follow-up `lookup` is guaranteed to see
    // the new token. The returned token is the only copy of the secret;
    // the caller MUST display/em它 inside an email body and never persist it.
    writeQueue = writeQueue.then(() => {
      const state = _readSync();
      state.byHash[hash] = record;
      if (Object.keys(state.byHash).length > MAX_TOKENS) {
        _capStore(state);
      }
      _writeSync(state);
    });
    // Block on the write so the caller can immediately `lookup` the token.
    // Each call returns a copy of `writeQueue` chained with our new write.
    return writeQueue.then(() => ({ token: raw, hash, expiresAt, email }));
  }

  /**
   * Look up a token record by raw token (not hash — caller passes what
   * arrived in the URL, we hash it for lookup). Does NOT mutate.
   *
   * @param {string} rawToken
   * @returns {Object|null}  Token record or null if not found / expired / invalid
   */
  function lookup(rawToken) {
    if (typeof rawToken !== 'string' || !rawToken) return null;
    const hash = _hashToken(rawToken);
    const state = _readSync();
    const record = state.byHash[hash];
    if (!record) return null;
    if (record.usedAt) return null;     // single-use
    if (Date.now() > record.expiresAt) return null;
    return { hash, ...record };
  }

  /**
   * Mark a token as used. Idempotent — second call is a no-op.
   *
   * @param {string} hash  Hex SHA-256 of the token
   * @param {number} at    Timestamp (default: now)
   */
  function markUsed(hash, at) {
    return _enqueueWrite(async (state) => {
      const record = state.byHash[hash];
      if (!record) return false;
      if (record.usedAt) return false;
      record.usedAt = at || Date.now();
      return true;
    });
  }

  /**
   * Count tokens issued to `email` within the last `windowMs` (default 1h).
   * Used for the per-email request-link rate limit.
   *
   * @param {string} email
   * @param {number} windowMs
   * @returns {number}
   */
  function countRecentForEmail(email, windowMs = 60 * 60 * 1000) {
    if (!email) return 0;
    const target = email.toLowerCase().trim();
    const since = Date.now() - windowMs;
    const state = _readSync();
    let n = 0;
    for (const record of Object.values(state.byHash)) {
      if (record.email === target && (record.issuedAt || 0) >= since) n++;
    }
    return n;
  }

  /**
   * Delete expired-and-used tokens (and very-old ones that somehow weren't
   * marked used). Safe to call any time; idempotent.
   */
  function prune() {
    return _enqueueWrite(async (state) => {
      const now = Date.now();
      for (const [hash, record] of Object.entries(state.byHash)) {
        const isUsed = !!record.usedAt;
        const isExpired = now > (record.expiresAt || 0);
        const isAncient = (record.issuedAt || 0) < (now - 7 * 24 * 60 * 60 * 1000);
        if ((isUsed && isExpired) || isAncient) delete state.byHash[hash];
      }
    });
  }

  function startPruneTimer() {
    if (pruneTimer) return;
    pruneTimer = setInterval(() => {
      prune().catch(() => { /* swallow — prune is best-effort */ });
    }, PRUNE_INTERVAL_MS);
    // Don't keep the event loop alive for this timer alone.
    if (typeof pruneTimer.unref === 'function') pruneTimer.unref();
  }

  function stopPruneTimer() {
    if (pruneTimer) {
      clearInterval(pruneTimer);
      pruneTimer = null;
    }
  }

  /** Test-only helper. Wipes the in-memory state and the file. */
  function _resetSync() {
    writeQueue = Promise.resolve();
    try { fs.unlinkSync(filePath); } catch { /* ignore */ }
  }

  return {
    issue,
    lookup,
    markUsed,
    countRecentForEmail,
    prune,
    startPruneTimer,
    stopPruneTimer,
    _resetSync,             // test-only
    get TOKEN_TTL_MS() { return TOKEN_TTL_MS; },
    get MAX_TOKENS() { return MAX_TOKENS; },
  };
}

/** Hash a raw token to its storage key. SHA-256 hex. */
function _hashToken(raw) {
  return crypto.createHash('sha256').update(raw, 'utf8').digest('hex');
}

module.exports = { createStore, _hashToken };
