/**
 * Tests for DC-085: link-first invite (Discord-style "share it however you want").
 *
 *   - default sendEmail omission = no email sent, link returned, no token in logs
 *   - sendEmail:true triggers SMTP send when configured
 *   - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked
 *   - shareText field present and well-formed in every response
 *   - acceptUrl always present (regardless of sendEmail)
 *   - role + ttl validation unchanged from DC-048
 *
 * Strategy: drive the route handler directly with mock req/res, mount the admin
 * router against an isolated userStore + inviteStore + email-sender stub.
 */

'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');

function _tmpDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-'));
}
function _cleanup(dir) {
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}

// Stub email-sender so we can assert "was it called?" without an SMTP server.
// NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()`
// call is allowed to reference it (Babel guard against out-of-scope access).
const mockEmailSender = {
  isConfigured: jest.fn(() => false),
  sendEmail: jest.fn(async () => undefined),
};
jest.mock('../src/auth/providers/email-sender', () => mockEmailSender);

describe('DC-085: link-first admin invites', () => {
  let dir, app, request;
  let logCalls; // captured { level, msg, meta } from our fake log

  beforeEach(async () => {
    jest.clearAllMocks();
    dir = _tmpDir();
    logCalls = [];

    // Set up email auth enable flag so userStore mounts.
    process.env.NODE_ENV = 'test';

    const { createUserStore } = require('../src/security/user-store');
    const userStore = createUserStore({ dataDir: dir });

    // Bootstrap the admin so we have a session-attributable user.
    await userStore.login({ email: 'admin@sami-host.me' });

    // Build a tiny Express app with the admin router mounted, but skip the
    // global auth gate (we inject req.user directly).
    const adminRouter = require('../routes/auth/admin')({
      asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
      errorResponse: (_res, code, msg) => ({ status: code, msg }),
      log: {
        info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }),
        warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }),
        error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }),
      },
      session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
      dataDir: dir,
    });

    app = express();
    app.use(express.json());
    // Inject req.user = admin so /admin/* passes the role gate.
    app.use((req, _res, next) => {
      req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
      req.app.locals = req.app.locals || {};
      req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers
      req.app.locals.emailConfig = null; // SMTP not configured by default
      next();
    });
    app.use('/api/v1/auth', adminRouter);
    // Error handler — last in chain.
    app.use((err, _req, res, _next) => {
      const code = (err && err.statusCode) || 500;
      res.status(code).json({
        success: false,
        error: err && err.message,
        code: err && err.code,
      });
    });

    request = require('supertest');
  });

  afterEach(() => _cleanup(dir));

  test('default sendEmail (omitted) returns link and does NOT send email', async () => {
    const res = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'friend@example.com', role: 'operator' });

    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
    expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/);
    expect(res.body.deliveredVia).toBe('manual');
  });

  test('default sendEmail does NOT log raw token to server log', async () => {
    const res = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'friend@example.com', role: 'operator' });

    const acceptUrl = res.body.acceptUrl;
    // Extract the token from the URL and verify it does NOT appear in any log call.
    const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
    const tokenLeaked = logCalls.some(c =>
      typeof c.msg === 'string' && c.msg.includes(token)
    );
    expect(tokenLeaked).toBe(false);

    // Also assert no log entry mentions the URL verbatim (the old
    // `[DC-048-DEV-INVITE-LINK] url=...` spam).
    const oldSpam = logCalls.find(c =>
      typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]')
    );
    expect(oldSpam).toBeUndefined();
  });

  test('shareText is present and well-formed in every response', async () => {
    const res = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 });

    expect(res.body.shareText).toBeDefined();
    expect(res.body.shareText).toContain('Join my DashCaddy');
    expect(res.body.shareText).toContain('operator');
    expect(res.body.shareText).toContain(res.body.acceptUrl);
    expect(res.body.shareText).toContain('expires in 24h');
  });

  test('acceptUrl is always returned regardless of sendEmail', async () => {
    const r1 = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'a@x.com', sendEmail: false });
    const r2 = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'b@x.com' });
    expect(r1.body.acceptUrl).toBeTruthy();
    expect(r2.body.acceptUrl).toBeTruthy();
  });

  test('sendEmail: true triggers SMTP send when configured', async () => {
    // Build a SECOND app instance where emailConfig is a real-looking object,
    // so isConfigured() returns true. The first app uses emailConfig=null.
    mockEmailSender.isConfigured.mockReturnValueOnce(true);
    mockEmailSender.sendEmail.mockResolvedValueOnce(undefined);
    const app2 = express();
    app2.use(express.json());
    app2.use((req, _res, next) => {
      req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
      req.app.locals = req.app.locals || {};
      req.app.locals.siteConfig = {};
      req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' };
      next();
    });
    const { createUserStore } = require('../src/security/user-store');
    const userStore2 = createUserStore({ dataDir: dir });
    await userStore2.login({ email: 'admin@sami-host.me' });
    const router2 = require('../routes/auth/admin')({
      asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
      errorResponse: (_res, code, msg) => ({ status: code, msg }),
      log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} },
      session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
      dataDir: dir,
    });
    app2.use('/api/v1/auth', router2);

    const res = await request(app2)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'friend@example.com', role: 'viewer', sendEmail: true });

    expect(res.status).toBe(200);
    expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1);
    const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0];
    expect(to).toBe('friend@example.com');
    expect(subject).toMatch(/invited/i);
    expect(text).toContain(res.body.acceptUrl);
    expect(html).toContain(res.body.acceptUrl);
    expect(res.body.deliveredVia).toBe('email');
  });

  test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => {
    mockEmailSender.isConfigured.mockReturnValueOnce(false);

    const res = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'friend@example.com', role: 'operator', sendEmail: true });

    expect(res.status).toBe(200);
    expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
    expect(res.body.deliveredVia).toBe('failed');
    // acceptUrl + shareText still present so the operator can share manually.
    expect(res.body.acceptUrl).toBeTruthy();
    expect(res.body.shareText).toBeTruthy();
    // Token does NOT appear in any log call.
    const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
    const tokenLeaked = logCalls.some(c =>
      typeof c.msg === 'string' && c.msg.includes(token)
    );
    expect(tokenLeaked).toBe(false);
  });

  test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => {
    // DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'`
    // silently substitutes default rather than throwing. This test pins that
    // behavior so a future "strict role validation" change is a deliberate
    // decision, not a silent regression.
    const res = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'a@x.com', role: 'superuser' });
    expect(res.status).toBe(200);
    expect(res.body.role).toBe('operator');
    expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
  });

  test('email validation: missing email still rejected', async () => {
    const res = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ role: 'operator' });
    expect(res.status).toBe(400);
    expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
  });

  test('ttlHours: 1 still produces shareText with correct expiry wording', async () => {
    const res = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'a@x.com', ttlHours: 1 });
    expect(res.body.shareText).toContain('expires in 1h');
  });

  test('DC-089: SMTP-unconfigured warn log masks the invite email (no raw PII)', async () => {
    mockEmailSender.isConfigured.mockReturnValueOnce(false);

    const res = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'friend@example.com', role: 'operator', sendEmail: true });

    expect(res.status).toBe(200);
    expect(res.body.deliveredVia).toBe('failed');
    const warn = logCalls.find(c =>
      c.level === 'warn' && c.topic === 'auth-invite-send'
    );
    expect(warn).toBeDefined();
    // The raw address must not appear; the masked form must.
    expect(JSON.stringify(warn.meta)).not.toContain('friend@example.com');
    expect(warn.meta.email).toBe('fr****@example.com');
  });

  test('DC-089: invite-accepted info log masks the created user email (no raw PII)', async () => {
    // Pre-authorize the email (POST /admin/users) so userStore.login doesn't
    // reject with not_authorized — bootstrap already happened in beforeEach.
    const preauth = await request(app)
      .post('/api/v1/auth/admin/users')
      .send({ email: 'newfriend@example.com' });
    expect(preauth.status).toBe(200);

    const issue = await request(app)
      .post('/api/v1/auth/admin/invites')
      .send({ email: 'newfriend@example.com', role: 'viewer' });
    expect(issue.status).toBe(200);
    const token = issue.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];

    const res = await request(app)
      .post(`/api/v1/auth/invites/${token}/accept`)
      .send({});

    expect(res.status).toBe(200);
    const info = logCalls.find(c =>
      c.level === 'info' && c.msg === 'invite accepted, user created'
    );
    expect(info).toBeDefined();
    expect(JSON.stringify(info.meta)).not.toContain('newfriend@example.com');
    expect(info.meta.email).toBe('ne****@example.com');
  });
});
