/**
 * Share routes — DC-053.
 *
 * Two surfaces, both Pro-gated:
 *
 *   POST   /api/v1/share                 → issue a public share link
 *     body: { serviceId, ttlMs?, subscribeCap? }
 *     ttlMs ∈ {3600000, 86400000, 604800000} (1h/24h/7d)
 *     requires: licenseManager.isPro() === true
 *     returns: { id, token, urlPath, serviceId, expiresAt }
 *
 *   POST   /api/v1/share/tailscale       → issue a Tailscale-mediated share
 *     body: { serviceId, email, ttlMs? }   (ttlMs ≤ 24h, default 24h)
 *     requires: licenseManager.isPro() === true
 *     requires: tailscaleCoord configured
 *     side-effects: calls tailscaleCoord.createAuthKey() (single-use, scoped)
 *                   + notificationManager.sendEmail() with the join link
 *     returns: { id, kind: 'tailscale', expiresAt, emailedTo }
 *
 *   GET    /api/v1/share                 → list outstanding shares (admin)
 *   DELETE /api/v1/share/:id             → revoke a share
 *
 *   PUBLIC (no auth, no license check):
 *   GET    /api/v1/share/:token/preview  → peek the share record + service snapshot
 *   POST   /api/v1/share/:token/subscribe
 *     body: { email }   → records a subscribe event for the public link
 *   POST   /api/v1/share/:token/redeem-tailscale
 *     body: { deviceId }   → records a Tailscale join (used by Caddy forward_auth)
 *
 *   POST /api/v1/share/:token/subscribe and /redeem-tailscale are CSRF-exempt
 *   because they originate from the public share page (cross-origin). Both
 *   are bound to a specific share token, so the abuse surface is bounded.
 */

'use strict';

const { ValidationError, NotFoundError } = require('../src/utilities/errors');
const { PaymentRequiredError } = require('../src/utilities/errors');
const { ok, created, badRequest, notFound } = require('../src/utils/responses');
// DC-083: route-layer validators for the public CSRF-exempt endpoints. These
// are imported from share-store so the route and store stay in lockstep
// (drift risk if one set is updated and the other is forgotten).
const { validatePublicEmail, validatePublicDeviceId } = require('../src/security/share-store');

const PUBLIC_TTL_OPTIONS = new Set([
  60 * 60 * 1000,
  24 * 60 * 60 * 1000,
  7 * 24 * 60 * 60 * 1000,
]);
const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000;

module.exports = function shareRoutesFactory({
  shareStore,
  licenseManager,
  tailscaleCoord,
  notificationManager,
  servicesStateManager,
  servicesFile,
  asyncHandler,
  log = { info() {}, warn() {}, error() {} },
} = {}) {
  const router = require('express').Router();

  // Share-store is required. In production this is always present (created in
  // src/app.js unconditionally). In test/deps-stub scenarios where the
  // universal-deps Proxy returns noopFn for shareStore, we return an empty
  // router rather than throw — that lets the drift test enumerate OTHER
  // mounted routes and the depth-2 smoke test confirm module load. Real
  // runtime errors will surface as 404s, not 500s.
  if (!shareStore || typeof shareStore.issuePublic !== 'function') {
    if (process.env.NODE_ENV === 'test') {
      log.warn && log.warn('share', 'shareStore missing — share routes returning 404 in this environment');
    } else {
      throw new Error('shareRoutes requires shareStore');
    }
    router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-553] share unavailable' }));
    return router;
  }
  if (!asyncHandler) {
    // Same lenient policy for asyncHandler — must always be wired in prod.
    if (process.env.NODE_ENV !== 'test') {
      throw new Error('shareRoutes requires asyncHandler');
    }
    // Fall back to a noop asyncHandler so route handlers can still register.
    asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
  }

  function _requireAuth(req, _res, next) {
    if (!req.user || !req.user.email) return next(new ValidationError('authentication required', 'auth'));
    next();
  }

  function _requireAdmin(req, _res, next) {
    const role = req.user && req.user.role;
    if (role !== 'admin') return next(new ValidationError('admin role required', 'role'));
    next();
  }

  function _requirePro(req, _res, next) {
    if (!licenseManager || typeof licenseManager.isPro !== 'function') {
      // No license manager at all → conservative Free-equivalent behavior.
      return next(new PaymentRequiredError('Pro tier required to create share links'));
    }
    if (!licenseManager.isPro()) {
      return next(new PaymentRequiredError('Pro tier required to create share links'));
    }
    next();
  }

  async function _loadService(serviceId) {
    // Prefer the in-memory state manager; fall back to a synchronous read of
    // services.json so the share-preview endpoint works even after a restart.
    let svc = null;
    if (servicesStateManager && typeof servicesStateManager.get === 'function') {
      try { svc = await servicesStateManager.get(serviceId); } catch (_) { svc = null; }
    }
    if (svc) return svc;
    if (servicesFile) {
      try {
        const fs = require('fs');
        const raw = fs.readFileSync(servicesFile, 'utf8');
        const parsed = JSON.parse(raw);
        const arr = Array.isArray(parsed) ? parsed : (parsed.services || []);
        svc = arr.find(s => s && (s.id === serviceId || s.name === serviceId));
      } catch (_) { svc = null; }
    }
    return svc;
  }

  function _serviceSnapshot(svc) {
    if (!svc) return null;
    return {
      id: svc.id || svc.name || null,
      name: svc.name || svc.id || null,
      description: svc.description || '',
      url: svc.url || (svc.domain ? `https://${svc.domain}` : null),
      icon: svc.icon || null,
      tags: Array.isArray(svc.tags) ? svc.tags : [],
      category: svc.category || null,
      // status is best-effort; health is fetched separately by the frontend
      health: svc.health || svc.status || 'unknown',
    };
  }

  // ─── Authenticated admin endpoints ────────────────────────────────────────

  router.post('/share', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => {
    const { serviceId, ttlMs, subscribeCap } = req.body || {};
    if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId');
    const service = await _loadService(serviceId);
    if (!service) throw new NotFoundError('service not found');

    const effectiveTtl = (typeof ttlMs === 'number' && PUBLIC_TTL_OPTIONS.has(ttlMs))
      ? ttlMs
      : 24 * 60 * 60 * 1000;

    const result = await shareStore.issuePublic({
      serviceId,
      ttlMs: effectiveTtl,
      createdBy: req.user.email,
      subscribeCap,
    });
    if (!result.ok) throw new ValidationError(result.reason || 'issue_failed', 'share');

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

    res.status(201).json({
      success: true,
      data: {
        id: result.id,
        kind: 'public',
        token: result.token,
        urlPath: result.urlPath,
        serviceId: result.serviceId,
        expiresAt: result.expiresAt,
        ttlMs: result.ttlMs,
      },
    });
  }, 'share-issue-public'));

  router.post('/share/tailscale', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => {
    const { serviceId, email, ttlMs } = req.body || {};
    if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId');
    if (!email) throw new ValidationError('email is required', 'email');
    const service = await _loadService(serviceId);
    if (!service) throw new NotFoundError('service not found');

    if (!tailscaleCoord || typeof tailscaleCoord.createAuthKey !== 'function') {
      throw new ValidationError('Tailscale is not configured on this host', 'tailscale');
    }

    const effectiveTtl = (typeof ttlMs === 'number' && ttlMs > 0)
      ? Math.min(ttlMs, MAX_TAILSCALE_TTL_MS)
      : MAX_TAILSCALE_TTL_MS;

    const issue = await shareStore.issueTailscale({
      serviceId,
      email,
      ttlMs: effectiveTtl,
      createdBy: req.user.email,
    });
    if (!issue.ok) throw new ValidationError(issue.reason || 'issue_failed', 'share');

    // Create the one-shot Tailscale pre-auth key. The auth-key string itself
    // is what we email — it never touches disk. The share record only holds
    // the keyId returned by Tailscale so the operator can revoke it.
    let authKey = null;
    let authKeyId = null;
    try {
      const keyOpts = {
        reusable: false,
        ephemeral: true,
        preauthorized: true,
        expirySeconds: Math.ceil(effectiveTtl / 1000),
        description: `dashcaddy-share:${issue.id}:${serviceId}`,
      };
      const key = await tailscaleCoord.createAuthKey(keyOpts);
      authKey = key && (key.key || key.value || (typeof key === 'string' ? key : null));
      authKeyId = key && key.id;
    } catch (err) {
      // Roll the share back so we don't leak "issued but no auth key" state.
      await shareStore.revoke(issue.id);
      log.error && log.error('share', 'tailscale createAuthKey failed', { err: err && err.message });
      throw new ValidationError('failed to mint Tailscale auth key', 'tailscale');
    }

    if (!authKey) {
      await shareStore.revoke(issue.id);
      throw new ValidationError('Tailscale returned no auth key', 'tailscale');
    }

    await shareStore.attachAuthKey(issue.id, authKeyId);

    // Email the join link to the invitee. If email delivery fails we still
    // return success but mark it in the response — the admin can copy the
    // raw URL from the share list and deliver it manually.
    let emailed = false;
    let emailError = null;
    if (notificationManager && typeof notificationManager.sendEmail === 'function') {
      try {
        const baseUrl = `${req.protocol}://${req.get('host') || 'status.sami'}`;
        const joinUrl = `${baseUrl}/share/${issue.token}`;
        await notificationManager.sendEmail(
          `[DashCaddy] ${req.user.email} shared a service with you`,
          [
            `You've been invited to access "${service.name || serviceId}" on DashCaddy.`,
            ``,
            `Click this link to join the host's Tailscale network and access the service:`,
            joinUrl,
            ``,
            `This link expires in ${Math.round(effectiveTtl / (60 * 60 * 1000))} hours and can only be used once.`,
          ].join('\n')
        );
        emailed = true;
      } catch (err) {
        emailError = err && err.message;
        log.warn && log.warn('share', 'email delivery failed; admin can copy the URL manually', {
          err: emailError,
        });
      }
    }

    log.info && log.info('share', 'tailscale share issued', {
      id: issue.id, serviceId, email: issue.email, emailed, authKeyId,
    });

    res.status(201).json({
      success: true,
      data: {
        id: issue.id,
        kind: 'tailscale',
        email: issue.email,
        serviceId,
        expiresAt: issue.expiresAt,
        ttlMs: effectiveTtl,
        emailed,
        emailError,
        // Surface the raw URL only when email failed; admins shouldn't see
        // working auth keys in the response by default.
        urlPath: emailed ? null : issue.urlPath,
      },
    });
  }, 'share-issue-tailscale'));

  router.get('/share', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => {
    const all = await shareStore.list();
    res.json({ success: true, data: all });
  }, 'share-list'));

  router.delete('/share/:id', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => {
    const okRevoked = await shareStore.revoke(req.params.id);
    if (!okRevoked) throw new NotFoundError('share not found');
    res.json({ success: true });
  }, 'share-revoke'));

  // ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────

  // DC-083: rate-limit the two CSRF-exempt public endpoints. The general
  // limiter (1000/15min) is mounted globally in app.js and is too generous
  // for unauthenticated state-mutating endpoints. 30/15min per IP is
  // enough for a legitimate user clicking "subscribe" once or twice; anything
  // beyond is abuse. Skipped in test envs via the standard isTest guard.
  // Lazy-loaded so test environments without the dep installed don't blow up;
  // a missing-dep in production logs a warning and falls back to no-op (still
  // safe — the route+store validators are the primary defense).
  const { RATE_LIMITS } = require('../src/utilities/constants');
  const isTest = process.env.NODE_ENV === 'test';
  let _sharePublicLimiter = (req, _res, next) => next(); // no-op default
  try {
    const rateLimit = require('express-rate-limit'); // eslint-disable-line global-require
    _sharePublicLimiter = rateLimit({
      ...RATE_LIMITS.SHARE_PUBLIC,
      standardHeaders: true,
      legacyHeaders: false,
      skip: () => isTest,
      message: { success: false, error: 'Too many share requests, please try again later' },
    });
  } catch (e) {
    // Don't crash on missing dep in a bare-bones env — but log so it's not
    // invisible if production misconfigured.
    if (log && typeof log.warn === 'function') {
      log.warn({ ctx: 'share-routes', err: e.message }, 'express-rate-limit unavailable; share public endpoints have NO rate limit');
    }
  }

  router.get('/share/:token/preview', _sharePublicLimiter, asyncHandler(async (req, res) => {
    const meta = await shareStore.peek(req.params.token);
    if (!meta) {
      return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
    }
    const service = await _loadService(meta.serviceId);
    res.json({
      success: true,
      data: {
        kind: meta.kind,
        serviceId: meta.serviceId,
        expiresAt: meta.expiresAt,
        service: _serviceSnapshot(service),
      },
    });
  }, 'share-preview'));

  router.post('/share/:token/subscribe', _sharePublicLimiter, asyncHandler(async (req, res) => {
    // DC-083: replace the primitive `email.includes('@')` check with a
    // charset/length/control-char-bounded validator. The pre-fix code
    // accepted `@`, `a@`, `<script>@x.c`, and 10MB strings as "valid email".
    // The subscribe body's `email` is now also captured to the share record
    // (capped to last 8 entries, see share-store recordPublicSubscribe) so
    // the operator can see who subscribed.
    const { email } = req.body || {};
    let normalizedEmail = null;
    if (email !== undefined && email !== null) {
      const v = validatePublicEmail(email);
      if (!v.ok) throw new ValidationError(v.reason, 'email');
      normalizedEmail = v.email;
    }
    const result = await shareStore.recordPublicSubscribe(req.params.token, { email: normalizedEmail });
    if (!result.ok) {
      if (result.reason === 'not_found') throw new NotFoundError('share not found');
      throw new ValidationError(result.reason, 'share');
    }
    res.json({ success: true, data: { count: result.count, cap: result.cap } });
  }, 'share-subscribe'));

  router.post('/share/:token/redeem-tailscale', _sharePublicLimiter, asyncHandler(async (req, res) => {
    // DC-083: replace the bare `typeof deviceId === 'string'` check with a
    // charset/length/control-char-bounded validator. The pre-fix code
    // accepted arbitrary strings of any length — including CR/LF/NUL,
    // which flow into the Tailscale auth-key description string in
    // POST /share/tailscale (routes/share.js:213 in the issue path).
    // The redeem-tailscale path receives the deviceId from Caddy's
    // forward_auth (a Tailscale machine ID), which is base64url +
    // hyphens — well within the validator's charset.
    const { deviceId } = req.body || {};
    let normalizedDeviceId = null;
    if (deviceId !== undefined && deviceId !== null) {
      const v = validatePublicDeviceId(deviceId);
      if (!v.ok) throw new ValidationError(v.reason, 'deviceId');
      normalizedDeviceId = v.deviceId;
    }
    const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId: normalizedDeviceId });
    if (!result.ok) {
      if (result.reason === 'not_found') throw new NotFoundError('share not found');
      throw new ValidationError(result.reason, 'share');
    }
    res.json({ success: true, data: { redeemed: true, share: result.share } });
  }, 'share-redeem-tailscale'));

  return router;
};

module.exports.PUBLIC_TTL_OPTIONS = PUBLIC_TTL_OPTIONS;