/**
 * Invite store — DC-048.
 *
 * Single-use invite tokens with TTL. Admin generates an invite for an email;
 * the system emails (or logs in dev) a magic-link-style URL containing the
 * raw token. The recipient clicks → accepts → becomes an authorized user.
 *
 * Storage: data/invites.json. Atomic writes via tmp+rename.
 *
 * Token shape:
 *   - 32 random bytes, base64url-encoded (256 bits of entropy).
 *   - We store ONLY the SHA-256 hash on disk. The raw token lives in the
 *     email + in the URL query string; on the server we hash and look up.
 *     A read-only compromise of invites.json cannot forge acceptance.
 *
 * Lifecycle:
 *   - issue({ email, role, ttlMs, invitedBy }) → { id, token, expiresAt, ... }
 *     token is the only time the raw token will ever be returned.
 *   - peek(token) → { email, role, expiresAt, usedAt } | null
 *     (returns the public-safe info without consuming the token)
 *   - accept(token) → { ok: true, invite } | { ok: false, reason }
 *     reasons: 'not_found', 'expired', 'already_used'
 *   - revoke(id) → removes the invite by id (admin-only).
 *   - list() → all outstanding invites (admin-only).
 */

'use strict';

const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');

const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d

function _nowMs() { return Date.now(); }
function _nowIso() { return new Date().toISOString(); }

function _atomicWriteJSON(filePath, data) {
  const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now();
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
  fs.renameSync(tmp, filePath);
}

function _readJSON(filePath, fallback) {
  try {
    const raw = fs.readFileSync(filePath, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    if (err && err.code === 'ENOENT') return fallback;
    return fallback;
  }
}

function _defaultData() { return { invites: {} }; }

function _sha256(s) {
  return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
}

function createInviteStore(opts = {}) {
  // Same defensive resolver as user-store — universal-deps test proxies
  // can return function-typed values for property access.
  const candidates = [
    opts.dataDir,
    opts.platformPaths && opts.platformPaths.dataDir,
    platformPaths && platformPaths.dataDir,
  ];
  const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
    || require('os').tmpdir();
  const log = opts.log || { info() {}, warn() {}, error() {} };

  const file = path.join(dataDir, 'invites.json');

  let _mutex = Promise.resolve();
  function _enqueue(fn) {
    const next = _mutex.then(fn, fn);
    _mutex = next.catch(() => {});
    return next;
  }

  function _load() {
    const data = _readJSON(file, _defaultData());
    if (!data.invites || typeof data.invites !== 'object') data.invites = {};
    return data;
  }
  function _save(data) { _atomicWriteJSON(file, data); }

  function _prune(data) {
    const cutoff = _nowMs() - PRUNE_AFTER_MS;
    for (const id of Object.keys(data.invites)) {
      const inv = data.invites[id];
      if (!inv) { delete data.invites[id]; continue; }
      const isTerminal = inv.usedAt || (inv.expiresAt && new Date(inv.expiresAt).getTime() < cutoff);
      if (isTerminal) delete data.invites[id];
    }
    return data;
  }

  /**
   * Issue a new invite. Returns the raw token (only time it leaves the system).
   */
  function issue({ email, role = 'operator', ttlMs = DEFAULT_TTL_MS, invitedBy = 'admin' } = {}) {
    return _enqueue(() => {
      if (typeof email !== 'string' || !email.includes('@')) {
        return { ok: false, reason: 'invalid_email' };
      }
      const normalized = email.toLowerCase().trim();
      const id = crypto.randomUUID();
      const token = crypto.randomBytes(32).toString('base64url');
      const hash = _sha256(token);
      const issuedAt = _nowIso();
      const expiresAt = new Date(_nowMs() + ttlMs).toISOString();

      const data = _load();
      _prune(data);
      data.invites[id] = {
        id,
        hash,
        email: normalized,
        role,
        invitedBy,
        issuedAt,
        expiresAt,
        usedAt: null,
        usedBy: null,
      };
      _save(data);

      log.info && log.info('invite', 'invite issued', {
        id, email: normalized, role, invitedBy, ttlMs,
      });

      return {
        ok: true,
        id,
        token,        // raw token — caller emails it
        email: normalized,
        role,
        expiresAt,
        ttlMs,
      };
    });
  }

  /**
   * Public-safe peek. Does NOT consume the token.
   * Returns null if not found, expired, or already used (same response
   * for all three — enumeration prevention).
   */
  function peek(token) {
    if (!token || typeof token !== 'string') return null;
    return _enqueue(() => {
      const data = _load();
      const hash = _sha256(token);
      const inv = _findByHash(data, hash);
      if (!inv) return null;
      if (inv.usedAt) return null;
      if (new Date(inv.expiresAt).getTime() < _nowMs()) return null;
      return {
        id: inv.id,
        email: inv.email,
        role: inv.role,
        expiresAt: inv.expiresAt,
        issuedAt: inv.issuedAt,
      };
    });
  }

  /**
   * Consume an invite token. Returns the invite record on success.
   * After accept(), the invite is marked used (NOT deleted) so the admin
   * can see who redeemed what. The auto-prune reaps it after 7 days.
   */
  function accept(token, { acceptedBy } = {}) {
    return _enqueue(() => {
      if (!token || typeof token !== 'string') {
        return { ok: false, reason: 'not_found' };
      }
      const data = _load();
      const hash = _sha256(token);
      const inv = _findByHash(data, hash);
      if (!inv) return { ok: false, reason: 'not_found' };
      if (inv.usedAt) return { ok: false, reason: 'already_used' };
      if (new Date(inv.expiresAt).getTime() < _nowMs()) {
        return { ok: false, reason: 'expired' };
      }

      inv.usedAt = _nowIso();
      inv.usedBy = acceptedBy || null;
      _save(data);

      log.info && log.info('invite', 'invite accepted', {
        id: inv.id, email: inv.email, role: inv.role, acceptedBy,
      });

      return {
        ok: true,
        invite: {
          id: inv.id,
          email: inv.email,
          role: inv.role,
          expiresAt: inv.expiresAt,
          usedAt: inv.usedAt,
        },
      };
    });
  }

  /**
   * Admin-only. Revoke an outstanding invite by id.
   */
  function revoke(id) {
    return _enqueue(() => {
      const data = _load();
      if (!data.invites[id]) return { ok: false, reason: 'not_found' };
      delete data.invites[id];
      _save(data);
      log.info && log.info('invite', 'invite revoked', { id });
      return { ok: true };
    });
  }

  /**
   * Admin-only. List outstanding invites (excludes used/expired).
   */
  function listOutstanding() {
    return _enqueue(() => {
      const data = _load();
      _prune(data);
      _save(data);
      const now = _nowMs();
      return Object.values(data.invites)
        .filter(inv => !inv.usedAt && new Date(inv.expiresAt).getTime() > now)
        .sort((a, b) => new Date(a.expiresAt) - new Date(b.expiresAt))
        .map(inv => ({
          id: inv.id,
          email: inv.email,
          role: inv.role,
          invitedBy: inv.invitedBy,
          issuedAt: inv.issuedAt,
          expiresAt: inv.expiresAt,
        }));
    });
  }

  function _findByHash(data, hash) {
    for (const id of Object.keys(data.invites)) {
      const inv = data.invites[id];
      if (inv && inv.hash === hash) return inv;
    }
    return null;
  }

  return {
    issue,
    peek,
    accept,
    revoke,
    listOutstanding,
    DEFAULT_TTL_MS,
  };
}

module.exports = { createInviteStore, DEFAULT_TTL_MS };