/**
 * Share store — DC-053.
 *
 * Signed share tokens that let the host share a service with non-authenticated
 * visitors. Two flavors:
 *
 *   1. **Public share links** — anonymous-readable preview URLs. Visitor sees
 *      a service card + status; no auth required. Host sets a TTL (1h / 24h /
 *      7d). Optional email subscribe to receive status-change notifications.
 *
 *   2. **Tailscale-mediated share** — a one-shot Tailscale pre-auth key scoped
 *      to a device tag. Invitee clicks the link → device joins the tailnet →
 *      Caddy forward_auth inducts them into the service. Single-use, 24h TTL.
 *
 * Storage: data/shares.json. Atomic writes via tmp+rename. The on-disk shape
 * is identical to the invite store — UUID-keyed map of records with SHA-256
 * hashed tokens. Raw token is only returned at issue() time.
 *
 * Public-share token also carries a HMAC signature binding it to the
 * serviceId so a leaked token cannot be silently retargeted. The signature
 * is verified at peek() time using a server-side secret (licenseManager's
 * install secret if available, otherwise a derived per-store key).
 *
 * Lifecycle:
 *   - issuePublic({ serviceId, ttlMs, createdBy }) → { id, token, url, expiresAt }
 *   - issueTailscale({ serviceId, email, ttlMs, createdBy }) → { id, token, url, expiresAt, authKeyId }
 *   - peek(token) → { kind, serviceId, expiresAt, remainingUses, usedAt? } | null
 *   - recordUse(token, { kind: 'public-subscribe' }) → { ok, count } | { ok: false, reason }
 *   - revoke(id) → boolean
 *   - list() → outstanding shares (admin view)
 *   - listForService(serviceId) → outstanding shares for a specific service
 */

'use strict';

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

const DEFAULT_PUBLIC_TTL_MS = 24 * 60 * 60 * 1000;     // 24h
const DEFAULT_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000;  // 24h
const MAX_PUBLIC_TTL_MS = 7 * 24 * 60 * 60 * 1000;     // 7d
const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000;      // 24h
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;        // auto-prune used/expired after 7d
const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000]);
const TAILSCALE_MAX_USES = 1;
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000;             // bound on subscribe events per link

// DC-083: Public share endpoint input bounds. The two CSRF-exempt public
// endpoints accept untrusted body fields — bound shape, length, charset so
// an attacker can't bloat data/shares.json, inject CRLF into fields that
// flow into Tailscale auth-key descriptions, or smuggle control chars into
// the on-disk store. See routes/share.js for the route-layer validation;
// these helpers are the defense-in-depth belt under the route's suspenders.
const PUBLIC_EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const PUBLIC_EMAIL_MAX_LENGTH = 254; // RFC 5321 §4.5.3.1.3
const PUBLIC_DEVICE_ID_REGEX = /^[a-zA-Z0-9._:-]+$/;
const PUBLIC_DEVICE_ID_MIN_LENGTH = 1;
const PUBLIC_DEVICE_ID_MAX_LENGTH = 128;

function validatePublicEmail(raw) {
  if (typeof raw !== 'string') return { ok: false, reason: 'invalid_email' };
  // Reject control chars / NUL / CR / LF before they can corrupt the on-disk
  // JSON or be embedded in subsequent log lines. RFC 5321 forbids these in
  // SMTP addresses; we mirror that at the API layer.
  if (raw.length === 0 || raw.length > PUBLIC_EMAIL_MAX_LENGTH) {
    return { ok: false, reason: 'invalid_email' };
  }
  // eslint-disable-next-line no-control-regex
  if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_email' };
  // The local-part can technically contain `+`, `.`, `_`, `%`, `-`; the
  // domain part must have at least one dot and a 2+ letter TLD. Reject
  // quote-bracket forms (RFC 5321 obs-quote-text) — we don't accept them.
  if (!PUBLIC_EMAIL_REGEX.test(raw)) return { ok: false, reason: 'invalid_email' };
  // Block obvious shell-attachment characters that the regex doesn't catch.
  if (/[<>{}|\\^`\s]/.test(raw)) return { ok: false, reason: 'invalid_email' };
  return { ok: true, email: raw.toLowerCase() };
}

function validatePublicDeviceId(raw) {
  if (typeof raw !== 'string') return { ok: false, reason: 'invalid_device_id' };
  if (raw.length < PUBLIC_DEVICE_ID_MIN_LENGTH || raw.length > PUBLIC_DEVICE_ID_MAX_LENGTH) {
    return { ok: false, reason: 'invalid_device_id' };
  }
  // Tailscale machine IDs are base64url-with-hyphens; we accept a slightly
  // broader charset (`._:-`) to also accommodate hostname-style IDs and
  // Caddy's `forward_auth` device headers. Reject CR/LF/NUL/TAB explicitly
  // so a smuggled control char can't break out of the Tailscale auth-key
  // description string in routes/share.js:213.
  // eslint-disable-next-line no-control-regex
  if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_device_id' };
  if (!PUBLIC_DEVICE_ID_REGEX.test(raw)) return { ok: false, reason: 'invalid_device_id' };
  return { ok: true, deviceId: raw };
}

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 { shares: {} }; }

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

function _hmacSign(secret, payload) {
  return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('base64url');
}

function createShareStore(opts = {}) {
  // Defensive resolver mirrors user-store / invite-store.
  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, 'shares.json');

  // Server-side secret. Prefer an explicit install secret if provided so the
  // signature can outlive a reinstall. Fall back to a random per-store key
  // persisted in dataDir (rotated on next start if the file moves).
  const _secretFile = path.join(dataDir, '.share-secret');
  function _loadSecret() {
    if (opts.signingSecret && typeof opts.signingSecret === 'string') {
      return opts.signingSecret;
    }
    try {
      const existing = fs.readFileSync(_secretFile, 'utf8').trim();
      if (existing && existing.length >= 32) return existing;
    } catch (_) { /* missing or unreadable — generate fresh */ }
    const fresh = crypto.randomBytes(32).toString('base64url');
    try {
      fs.writeFileSync(_secretFile, fresh + '\n', { mode: 0o600 });
    } catch (err) {
      log.warn && log.warn('share', 'failed to persist signing secret', { err: err && err.message });
    }
    return fresh;
  }
  const signingSecret = _loadSecret();

  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.shares || typeof data.shares !== 'object') data.shares = {};
    return data;
  }
  function _save(data) { _atomicWriteJSON(file, data); }

  function _prune(data) {
    const cutoff = _nowMs() - PRUNE_AFTER_MS;
    for (const id of Object.keys(data.shares)) {
      const s = data.shares[id];
      if (!s) { delete data.shares[id]; continue; }
      const isTerminal = (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP))
        || (s.kind === 'tailscale' && s.usedAt)
        || (s.expiresAt && new Date(s.expiresAt).getTime() < cutoff);
      if (isTerminal) delete data.shares[id];
    }
    return data;
  }

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

  function _verifySignature(s, token) {
    if (!s.signature || !s.serviceId) return false;
    const expected = _hmacSign(signingSecret, `${s.kind}:${s.id}:${s.serviceId}:${token}`);
    // constant-time compare; both are base64url strings of equal length
    const a = Buffer.from(s.signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length) return false;
    return crypto.timingSafeEqual(a, b);
  }

  function _publicView(s) {
    return {
      kind: s.kind,
      id: s.id,
      serviceId: s.serviceId,
      expiresAt: s.expiresAt,
      createdAt: s.createdAt,
      createdBy: s.createdBy,
      usedAt: s.usedAt || null,
      usedBy: s.usedBy || null,
      remainingUses: s.kind === 'tailscale' ? (s.usedAt ? 0 : 1) : Infinity,
      subscribeCount: s.subscribeCount || 0,
      subscribeCap: s.subscribeCap || null,
    };
  }

  function issuePublic({ serviceId, ttlMs = DEFAULT_PUBLIC_TTL_MS, createdBy = 'admin', subscribeCap } = {}) {
    return _enqueue(() => {
      if (typeof serviceId !== 'string' || !serviceId.trim()) {
        return { ok: false, reason: 'invalid_service' };
      }
      // Clamp TTL to allowed set so share links can't outlive their visibility intent.
      const effectiveTtl = ALLOWED_PUBLIC_TTLS.has(ttlMs) ? ttlMs : DEFAULT_PUBLIC_TTL_MS;
      const id = crypto.randomUUID();
      const token = crypto.randomBytes(32).toString('base64url');
      const hash = _sha256(token);
      const signature = _hmacSign(signingSecret, `public:${id}:${serviceId}:${token}`);
      const createdAt = _nowIso();
      const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString();
      const cap = Number.isInteger(subscribeCap) && subscribeCap > 0
        ? Math.min(subscribeCap, 10000)
        : PUBLIC_DEFAULT_SUBSCRIBE_CAP;

      const data = _load();
      _prune(data);
      data.shares[id] = {
        id,
        kind: 'public',
        hash,
        signature,
        serviceId,
        createdBy,
        createdAt,
        expiresAt,
        ttlMs: effectiveTtl,
        usedAt: null,
        usedBy: null,
        subscribeCount: 0,
        subscribeCap: cap,
      };
      _save(data);

      log.info && log.info('share', 'public share issued', {
        id, serviceId, createdBy, ttlMs: effectiveTtl,
      });

      return {
        ok: true,
        id,
        token,
        signature,
        kind: 'public',
        serviceId,
        expiresAt,
        ttlMs: effectiveTtl,
        urlPath: `/share/${token}`,
      };
    });
  }

  function issueTailscale({ serviceId, email, ttlMs = DEFAULT_TAILSCALE_TTL_MS, createdBy = 'admin' } = {}) {
    return _enqueue(() => {
      if (typeof serviceId !== 'string' || !serviceId.trim()) {
        return { ok: false, reason: 'invalid_service' };
      }
      if (typeof email !== 'string' || !email.includes('@')) {
        return { ok: false, reason: 'invalid_email' };
      }
      // Tailscale pre-auth keys max at 90 days but our share-window is 24h.
      const effectiveTtl = Math.max(60 * 1000, Math.min(ttlMs, MAX_TAILSCALE_TTL_MS));
      const id = crypto.randomUUID();
      const token = crypto.randomBytes(32).toString('base64url');
      const hash = _sha256(token);
      const signature = _hmacSign(signingSecret, `tailscale:${id}:${serviceId}:${token}`);
      const createdAt = _nowIso();
      const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString();

      const data = _load();
      _prune(data);
      data.shares[id] = {
        id,
        kind: 'tailscale',
        hash,
        signature,
        serviceId,
        email: email.toLowerCase().trim(),
        createdBy,
        createdAt,
        expiresAt,
        ttlMs: effectiveTtl,
        usedAt: null,
        usedBy: null,
        // authKeyId + authKey are written by the route layer after calling
        // tailscale-coord.createAuthKey(); peek() doesn't surface them.
        authKeyId: null,
      };
      _save(data);

      log.info && log.info('share', 'tailscale share issued', {
        id, serviceId, email: email.toLowerCase().trim(), createdBy, ttlMs: effectiveTtl,
      });

      return {
        ok: true,
        id,
        token,
        signature,
        kind: 'tailscale',
        serviceId,
        email: email.toLowerCase().trim(),
        expiresAt,
        ttlMs: effectiveTtl,
        urlPath: `/share/${token}`,
      };
    });
  }

  function attachAuthKey(id, authKeyId) {
    return _enqueue(() => {
      const data = _load();
      const s = data.shares[id];
      if (!s) return { ok: false, reason: 'not_found' };
      if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' };
      s.authKeyId = authKeyId;
      _save(data);
      return { ok: true };
    });
  }

  function peek(token) {
    if (!token || typeof token !== 'string') return null;
    return _enqueue(() => {
      const data = _load();
      const hash = _sha256(token);
      const s = _findByHash(data, hash);
      if (!s) return null;
      if (!_verifySignature(s, token)) {
        log.warn && log.warn('share', 'peek rejected: bad signature', { id: s.id });
        return null;
      }
      if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) return null;
      if (s.kind === 'tailscale' && s.usedAt) return null;
      if (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP)) {
        return null;
      }
      return _publicView(s);
    });
  }

  function getRaw(token) {
    if (!token || typeof token !== 'string') return null;
    return _enqueue(() => {
      const data = _load();
      const hash = _sha256(token);
      const s = _findByHash(data, hash);
      if (!s) return null;
      if (!_verifySignature(s, token)) return null;
      return s;
    });
  }

  function recordPublicSubscribe(token, { email } = {}) {
    return _enqueue(() => {
      // DC-083: validate the optional subscriber email at the store layer too.
      // The route layer validates first; this is the defense-in-depth catch
      // for direct callers (cron sweepers, internal jobs, future endpoints).
      // `email` is OPT-IN — callers omitting it get the original behavior.
      let normalizedEmail = null;
      if (email !== undefined && email !== null) {
        const v = validatePublicEmail(email);
        if (!v.ok) return { ok: false, reason: v.reason };
        normalizedEmail = v.email;
      }
      const data = _load();
      const hash = _sha256(token);
      const s = _findByHash(data, hash);
      if (!s) return { ok: false, reason: 'not_found' };
      if (s.kind !== 'public') return { ok: false, reason: 'wrong_kind' };
      if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' };
      if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) {
        return { ok: false, reason: 'expired' };
      }
      const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
      if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
      s.subscribeCount += 1;
      // DC-083: record the last submitting email (capped to 8 entries to
      // bound the on-disk size). PII minimization — we keep only the hash
      // + last 8 emails; full email log would grow unbounded.
      if (normalizedEmail) {
        if (!Array.isArray(s.subscriberEmails)) s.subscriberEmails = [];
        s.subscriberEmails.push(normalizedEmail);
        if (s.subscriberEmails.length > 8) {
          s.subscriberEmails.splice(0, s.subscriberEmails.length - 8);
        }
      }
      _save(data);
      return { ok: true, count: s.subscribeCount, cap };
    });
  }

  function recordTailscaleUse(token, { deviceId } = {}) {
    return _enqueue(() => {
      // DC-083: validate deviceId at the store layer. The pre-fix code
      // accepted ANY string of any length, including control chars and
      // CR/LF — which would flow into the Tailscale auth-key description
      // (routes/share.js:213) and into the on-disk shares.json. Reject
      // early so an attacker can't bloat the store or smuggle characters
      // out of the Tailscale description field.
      let normalizedDeviceId = 'unknown';
      if (deviceId !== undefined && deviceId !== null) {
        const v = validatePublicDeviceId(deviceId);
        if (!v.ok) return { ok: false, reason: v.reason };
        normalizedDeviceId = v.deviceId;
      }
      const data = _load();
      const hash = _sha256(token);
      const s = _findByHash(data, hash);
      if (!s) return { ok: false, reason: 'not_found' };
      if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' };
      if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' };
      if (s.usedAt) return { ok: false, reason: 'already_used' };
      if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) {
        return { ok: false, reason: 'expired' };
      }
      s.usedAt = _nowIso();
      s.usedBy = normalizedDeviceId;
      _save(data);
      return { ok: true, share: _publicView(s) };
    });
  }

  function revoke(id) {
    return _enqueue(() => {
      const data = _load();
      if (!data.shares[id]) return false;
      delete data.shares[id];
      _save(data);
      log.info && log.info('share', 'share revoked', { id });
      return true;
    });
  }

  function list() {
    return _enqueue(() => {
      const data = _load();
      _prune(data);
      return Object.values(data.shares).map(_publicView);
    });
  }

  function listForService(serviceId) {
    return _enqueue(() => {
      const data = _load();
      _prune(data);
      return Object.values(data.shares)
        .filter(s => s.serviceId === serviceId)
        .map(_publicView);
    });
  }

  return {
    issuePublic,
    issueTailscale,
    attachAuthKey,
    peek,
    getRaw,
    recordPublicSubscribe,
    recordTailscaleUse,
    revoke,
    list,
    listForService,
    // expose for tests
    _signingSecret: signingSecret,
    _file: file,
  };
}

module.exports = { createShareStore, validatePublicEmail, validatePublicDeviceId };