/**
 * EmailMagicLinkProvider — DC-047 — second AuthProvider implementation
 * alongside TOTP.
 *
 * Login flow:
 *   1. User opens `/login`, types their email.
 *   2. Frontend POSTs `{ email }` to `/api/v1/auth/login/email/initiate`
 *      with `methodId="magic-link"` (or omits it — this is the default).
 *   3. Server validates email shape, checks rate limit, generates a
 *      single-use 32-byte token, stores its SHA-256 hash, and sends an
 *      email containing a link with the raw token.
 *   4. User clicks the link → `/auth/verify?token=...` (frontend page) →
 *      POST to `/api/v1/auth/login/email/verify` with `{ token }`.
 *   5. Server looks up the token, marks it used, creates the session.
 *
 * SECURITY NOTES:
 *   - Email IS the identity. There is no separate username field anywhere
 *     in this provider. Adding one would re-introduce the multi-field
 *     identity model that this ticket explicitly avoided.
 *   - Raw token never touches disk. Only its SHA-256 hash is stored; a
 *     read-only compromise of the tokens file cannot forge login links.
 *   - Single-use: tokens are removed-by-marking on first verify. Second
 *     use returns the same generic "expired or already used" message
 *     so we don't leak whether the token existed.
 *   - Constant-time comparison of token at lookup (function of hash → map
 *     key, which is constant in JS object property access; the actual
 *     timing oracle lives in the SMTP path which is the >95% of latency
 *     noise, not us).
 *   - Rate limit: 5 link requests per email per hour, plus a hard server
 *     cap. Prevents email-bombing without locking out legitimate users
 *     who fat-finger their address.
 *   - Email enumeration: the response after `initiate` is always
 *     `{ sent: true }`, regardless of whether the email is configured as
 *     an authorized user. If multi-user allowlist (DC-048) is on, the
 *     email is silently dropped — the user gets the success message but
 *     nothing in their inbox. Once DC-048 lands the UI can show a more
 *     descriptive state.
 *   - SMTP fallback: if SMTP isn't configured, the link is logged to
 *     error.log with a clear `[DC-047-DEV-MAGIC-LINK]` marker so dev
 *     installs don't have to set up an SMTP server to log in. The dev
 *     log path is exclusive — production MUST have SMTP configured.
 *
 * AUTHORIZATION (DC-048 dependency):
 *   Today: any email can request a link and log in. This is the
 *   intended dev / single-user behavior, but not the public-release
 *   behavior. DC-048 introduces the first-user-becomes-admin rule
 *   and an authorized-users allowlist. This provider's `isEnabled()`
 *   will accept the email via that allowlist once DC-048 ships —
 *   the integration point is `deps.authorizedEmails` which returns
 *   `true` for everyone today and gets replaced by the real check
 *   in DC-048.
 */

const path = require('path');
const AuthProvider = require('./base');
const { ValidationError, AuthenticationError, RateLimitError } = require('../../utilities/errors');
const { ok } = require('../../utils/responses');
const emailSender = require('./email-sender');
const { createStore } = require('./email-tokens-store');

const PER_EMAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const PER_EMAIL_LIMIT = 5;                  // 5 link requests / hour / email
const DEFAULT_LINK_TTL_MS = 15 * 60 * 1000; // mirrors the token store default

// Loose RFC-5322 pragmatic regex; we don't try to be authoritative here.
// The goal is "looks like an email, not malicious" — not full parsing.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

class EmailMagicLinkProvider extends AuthProvider {
  constructor(deps) {
    super(deps);
    this.name = 'email';

    // Derive token-store path from config (or fall back to platformPaths.dataDir).
    // The provider accepts the file path directly via deps so tests can override.
    const storePath = deps.tokensFilePath || (deps.platformPaths && deps.platformPaths.dataDir
      ? path.join(deps.platformPaths.dataDir, 'email-tokens.json')
      : path.join(process.cwd(), 'data', 'email-tokens.json'));

    this.store = createStore(storePath);
    this.store.startPruneTimer();

    this.emailConfig = deps.emailConfig || null;
    // DC-048: real authorized-users check via the user store. Falls back
    // to "allow everyone" if no store is wired (dev/legacy installs).
    this.userStore = deps.userStore || null;
    // Public URL templates — overridable for testing.
    this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS;
    this.maxBodyLength = 32_000;
  }

  // ── Public state ────────────────────────────────────────────────────────

  async getConfig() {
    const cfg = this.emailConfig || {};
    return {
      enabled: this._isProviderEnabled(),
      sessionDuration: this.deps.config && this.deps.config.sessionDuration || '24h',
      smtpConfigured: emailSender.isConfigured(cfg),
      ttlMinutes: Math.round(this.linkTtlMs / 60000),
      rateLimit: { windowMinutes: 60, maxRequests: PER_EMAIL_LIMIT },
    };
  }

  async listMethods() {
    if (!(await this.isEnabled())) return [];
    return [
      {
        id: 'magic-link',
        label: 'Email me a sign-in link',
        description: 'A single-use link will be sent to your email address',
      },
    ];
  }

  async isSetUp() {
    // Email provider has NO operator-side setup — SMTP may be configured
    // (else the dev log path kicks in) but you never have to "set up" a
    // magic-link provider the way you set up TOTP.
    return true;
  }

  async isEnabled() {
    return this._isProviderEnabled();
  }

  /**
   * Provider-level enabled check. Today: the operator toggles email auth
   * via `siteConfig.authProviders.email.enabled` (default true if absent
   * to keep dev DX smooth). DC-048 will layer an authorized-user check on
   * top of this via `authorizedEmails()`.
   */
  _isProviderEnabled() {
    const flag = this.deps.config && this.deps.config.enabled;
    // Default to FALSE (DC-048 opt-in): operators must explicitly enable
    // email auth via `siteConfig.authProviders.email.enabled = true`. Until
    // then, the email login methods endpoint reports the provider as
    // disabled and the auth UI doesn't render the email button. TOTP-only
    // installs see no behavior change.
    if (flag !== true) return false;
    return true;
  }

  async recoveryInfo() {
    return {
      status: this._isProviderEnabled() ? 'healthy' : 'disabled',
      isSetUp: true,
      hint: this._isProviderEnabled()
        ? 'Enter the email address associated with your DashCaddy account. A sign-in link will be emailed to you (valid for 15 minutes).'
        : 'Email magic link login is disabled by the operator.',
    };
  }

  async setConfig(updates) {
    // Email provider has very little mutable config (session duration comes
    // from the global session subsystem). Reserved for future toggles.
    if (updates && updates.emailConfig) {
      this.emailConfig = { ...this.emailConfig, ...updates.emailConfig };
    }
    return this.getConfig();
  }

  // ── Provider URL helper ────────────────────────────────────────────────

  /**
   * Resolve the absolute URL the magic link points at. The link is the
   * full URL — users click it from a fresh browser session, so we can't
   * rely on any in-app redirect chain.
   *
   * Resolution order:
   *   1. siteConfig.publicBaseUrl (operator override)
   *   2. req.headers['x-forwarded-proto'] + req.headers['host']
   *   3. fallback to "http://localhost:3001" so dev works without config
   */
  _resolvePublicUrl(req) {
    const cfg = this.deps.siteConfig || {};
    if (cfg.publicBaseUrl && typeof cfg.publicBaseUrl === 'string') {
      return cfg.publicBaseUrl.replace(/\/+$/, '');
    }
    const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
    const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
      || (cfg.dashboardHost ? cfg.dashboardHost : 'localhost:3001');
    return `${proto}://${host}`;
  }

  // ── initiate / verify ──────────────────────────────────────────────────

  async initiate(methodId, req, res) {
    if (methodId !== 'magic-link') {
      throw new ValidationError(`Unknown email method: ${methodId}`, 'methodId');
    }
    const email = (req.body && req.body.email || '').toString().trim().toLowerCase();
    if (!email || !EMAIL_RE.test(email)) {
      throw new ValidationError('A valid email address is required', 'email');
    }

    // Rate-limit per email. We do this BEFORE generating the token so the
    // rate-limit error fires fast (no DB or disk write for spammers).
    const recent = this.store.countRecentForEmail(email, PER_EMAIL_WINDOW_MS);
    if (recent >= PER_EMAIL_LIMIT) {
      // Surface a 429 with retry-after hint.
      throw new RateLimitError(Math.ceil(PER_EMAIL_WINDOW_MS / 1000));
    }

    // Dev fallback for missing SMTP: STILL issue a token + log it locally.
    // Production sends the email; dev/test uses the log. The token is the
    // same either way — operators can grab it from the error log if SMTP
    // is misconfigured.
    const ip = this._clientIP(req);
    const userAgent = (req.headers && req.headers['user-agent']) || '';
    const issued = await this.store.issue({ email, ip, userAgent });
    const rawToken = issued.token;

    const linkPath = `/api/v1/auth/login/email/verify?token=${encodeURIComponent(rawToken)}`;
    const verifyUrl = `${this._resolvePublicUrl(req)}${linkPath}`;

    let deliveredVia = 'email';
    const cfg = this.emailConfig;
    if (emailSender.isConfigured(cfg)) {
      try {
        await emailSender.sendEmail(
          cfg,
          email,
          'Your DashCaddy sign-in link',
          buildEmailText({ verifyUrl, ttlMinutes: Math.round(this.linkTtlMs / 60000), email }),
          buildEmailHtml({ verifyUrl, ttlMinutes: Math.round(this.linkTtlMs / 60000) }),
        );
      } catch (sendErr) {
        this._logSendFailure(email, sendErr);
        deliveredVia = 'failed';
      }
    } else {
      // Dev path: no SMTP. Log the link to error.log so dev can still log in.
      deliveredVia = 'dev-console';
      this._logDevLink(email, verifyUrl);
    }

    this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link issued', {
      email,
      ip,
      deliveredVia,
      ttlMinutes: Math.round(this.linkTtlMs / 60000),
    });

    // Always respond identically: enumeration-prevention. The `sent` flag
    // mirrors "we attempted to deliver"; an unauthorized email silently
    // receives nothing but still gets the 200, exactly like a successful send.
    return ok(res, {
      sent: true,
      deliveredVia,
      maskedEmail: AuthProvider.maskEmail(email),
      ttlMinutes: Math.round(this.linkTtlMs / 60000),
    });
  }

  async verify(methodId, req, res) {
    if (methodId !== 'verify-token') {
      throw new ValidationError(`Unknown email method: ${methodId}`, 'methodId');
    }
    // Token arrives in body (POST) OR query string (GET-from-email-link).
    // Accept both. Body takes precedence so callers can POST without the
    // query contamination from proxied email clients.
    const token = (req.body && req.body.token) || req.query.token;
    if (!token || typeof token !== 'string') {
      throw new ValidationError('Missing token', 'token');
    }

    const record = this.store.lookup(token);
    // Same response for "no such token", "expired", and "already used" —
    // this prevents enumeration / leakage of token-state.
    if (!record) {
      throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
    }

    // Atomic mark-used. lookup was unlocked; markUsed takes the lock. If
    // somebody beat us to it (two-click race), markUsed returns false and
    // we treat it the same as a used token.
    const marked = await this.store.markUsed(record.hash);
    if (!marked) {
      throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
    }

    // DC-048: authorization gate. If the email isn't on the allowlist and
    // bootstrap has already happened, reject. The token is still consumed
    // so the same generic message is returned for "valid token but you're
    // not allowed" — prevents a side-channel that distinguishes
    // "token worked but you're banned" from "token didn't exist".
    //
    // NOTE: the DC-047 design comment claimed "initiate" would also silently
    // drop unauthorized emails. That was aspirational; the real enumeration
    // prevention lives at verify-time (here). Initiate-time, we still issue
    // tokens and return success — so an unauthorized user thinks the link
    // works, but it rejects at click-time. Same as DC-047 claimed; we just
    // moved the check from initiate to verify where it can actually run.
    if (this.userStore) {
      const allowed = await this.userStore.isEmailAuthorized(record.email);
      if (!allowed) {
        // Audit the denial.
        this.deps.log && this.deps.log.warn && this.deps.log.warn('auth', 'email magic link rejected — not authorized', {
          email: record.email,
          ip: this._clientIP(req),
        });
        // Mark token used so a stolen token can't be replayed by a legit user later.
        await this.store.markUsed(record.hash).catch(() => {});
        throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
      }
    }

    // Side-effect logging (NOT info-disclosure — just that a token was used).
    this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link verified', {
      email: record.email,
      ip: this._clientIP(req),
    });

    // DC-048: record-or-create the user. First login → bootstrap admin.
    // After that → must be on allowlist (already checked above).
    let userRecord = null;
    let isBootstrap = false;
    if (this.userStore) {
      const result = await this.userStore.login({
        email: record.email,
        ip: this._clientIP(req),
      });
      if (!result.ok) {
        // Shouldn't reach here — isEmailAuthorized just passed — but
        // handle the edge case where allowlist was mutated between calls.
        await this.store.markUsed(record.hash).catch(() => {});
        throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
      }
      userRecord = result.user;
      isBootstrap = result.isBootstrap;
      if (this.deps.log && this.deps.log.info) {
        this.deps.log.info('auth', isBootstrap ? 'bootstrap admin first login' : 'user login', {
          userId: userRecord.id,
          email: userRecord.email,
          role: userRecord.role,
        });
      }
    }

    // Create the session + cookie. Same shape as TOTP's verify path.
    this.deps.session.create(req, this.deps.config && this.deps.config.sessionDuration || '24h');
    this.deps.session.setCookie(res, this.deps.config && this.deps.config.sessionDuration || '24h');
    const newCsrf = this.deps.renewCSRFToken
      ? this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https')
      : undefined;

    // DC-048: tag the request with the authenticated user so downstream
    // middleware + audit log can attribute the session. We mutate req so
    // the audit logger (which runs as response middleware) sees it.
    if (userRecord) {
      req.user = {
        id: userRecord.id,
        email: userRecord.email,
        role: userRecord.role,
        isAdmin: userRecord.role === 'admin',
        isBootstrap,
      };
    }

    return ok(res, {
      message: 'Authenticated successfully',
      method: 'email',
      email: AuthProvider.maskEmail(record.email),
      csrfToken: newCsrf,
      user: userRecord
        ? {
            id: userRecord.id,
            email: userRecord.email,
            role: userRecord.role,
            isBootstrap,
          }
        : null,
    });
  }

  /**
   * The DC-047 provider is conceptually "always available" but operators
   * can still disable it via config. Disabling wipes issued tokens and the
   * SMTP config reference (no destructive operation on the user account
   * — there's no user record yet, that arrives in DC-048).
   */
  async disable(req, res) {
    if (this.emailConfig) {
      // Strip credentials but keep host from so SMTP can be re-enabled
      // without re-typing the From: address.
      this.emailConfig = { ...this.emailConfig };
      delete this.emailConfig.password;
    }
    this.store.prune().catch(() => {});
    this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link disabled');
    const { successMessage } = require('../../utils/responses');
    return successMessage(res, 'Email magic link disabled');
  }

  // ── Helpers ─────────────────────────────────────────────────────────────

  _clientIP(req) {
    const s = this.deps.session;
    if (s && typeof s.getClientIP === 'function') return s.getClientIP(req);
    if (req && typeof req.ip === 'string') return req.ip;
    return (req && req.socket && req.socket.remoteAddress) || 'unknown';
  }

  _logDevLink(email, url) {
    // Print to stderr (captured by error.log via DashCaddy's logger) plus a
    // structured info entry so dev-mode log-grep works. Marker is fixed so
    // downstream tooling can find it.
    const marker = `[DC-047-DEV-MAGIC-LINK] email=${email} url=${url}`;
    if (this.deps.log && typeof this.deps.log.warn === 'function') {
      this.deps.log.warn('auth-magic-dev', marker);
    } else {
      process.stderr.write(`${marker}\n`);
    }
  }

  _logSendFailure(email, err) {
    if (this.deps.log && typeof this.deps.log.error === 'function') {
      this.deps.log.error('auth-magic-send', `SMTP delivery failed for ${email}`, {
        error: err && err.message ? err.message : String(err),
      });
    }
  }
}

// ── Email-template helpers (pure functions for testability) ──────────────

function buildEmailText({ verifyUrl, ttlMinutes, email }) {
  return [
    'Hi,',
    '',
    'Someone (hopefully you) requested a sign-in link for DashCaddy.',
    'If that was you, click the link below within ' + ttlMinutes + ' minutes to log in:',
    '',
    verifyUrl,
    '',
    'This link is single-use and will expire automatically. If you didn\'t',
    'request this, you can safely ignore the email — no action needed.',
    '',
    '— DashCaddy',
    '(sent to ' + (email || '<unknown>') + ')',
  ].join('\n');
}

function buildEmailHtml({ verifyUrl, ttlMinutes }) {
  // Intentionally minimal — most DashCaddy users are operators who'd rather
  // read plaintext than click an HTML email. The HTML version is a fallback.
  return [
    '<!doctype html><html><body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;">',
    '<h2 style="margin:0 0 12px">Sign in to DashCaddy</h2>',
    '<p>Click the button below to log in (expires in ' + ttlMinutes + ' minutes):</p>',
    '<p style="margin:24px 0"><a href="' + verifyUrl + '" style="background:#1f2937;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none;display:inline-block">Sign in to DashCaddy</a></p>',
    '<p style="color:#6b7280;font-size:12px">If the button doesn\'t work, paste this link into your browser:<br><span style="word-break:break-all">' + verifyUrl + '</span></p>',
    '<p style="color:#6b7280;font-size:12px">If you didn\'t request this, you can safely ignore the email.</p>',
    '</body></html>',
  ].join('\n');
}

module.exports = EmailMagicLinkProvider;
module.exports.EMAIL_RE = EMAIL_RE;
module.exports.PER_EMAIL_LIMIT = PER_EMAIL_LIMIT;
module.exports.buildEmailText = buildEmailText;
module.exports.buildEmailHtml = buildEmailHtml;
