const express = require('express');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const request = require('supertest');
const createSsoRouter = require('../routes/auth/sso-gate');

function loadCredentialVaultHandoff() {
  const source = fs.readFileSync(
    path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
    'utf8',
  );
  const window = { location: { origin: 'https://status.sami' } };
  vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
  return window.DCCredentialVault;
}

function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
  const app = express();
  const session = {
    redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
    setCookieHostOnly: jest.fn((res) => {
      res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
    }),
    isValid: jest.fn().mockReturnValue(valid),
    createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
  };
  const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
  const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
  const router = createSsoRouter({
    totpConfig: { enabled: true, sessionDuration: '24h' },
    session,
    asyncHandler,
    errorResponse,
    log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
    getAppSession: jest.fn(),
    appSessionCache: new Map(),
    credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
    fetchT: jest.fn(),
    getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
    licenseManager: {
      hasFeature: jest.fn().mockReturnValue(true),
      requirePremium: jest.fn(() => (_req, _res, next) => next()),
    },
    servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
    siteConfig: { dashboardHost },
  });
  app.use('/api/v1', router);
  return { app, session };
}

describe('cross-host SSO exchange redirect', () => {
  test('sets a host-only cookie and redirects to a relative service path', async () => {
    const { app, session } = createApp();
    const res = await request(app)
      .get('/api/v1/auth/sso-exchange')
      .query({ token: 'one-time', return: '/settings?tab=network#dns' });

    expect(res.status).toBe(303);
    expect(res.headers.location).toBe('/settings?tab=network#dns');
    expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
    expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
  });

  test.each([
    'https://evil.example/phish',
    '//evil.example/phish',
    '/\\evil.example/phish',
  ])('rejects cross-origin return value %s', async (returnValue) => {
    const { app } = createApp();
    const res = await request(app)
      .get('/api/v1/auth/sso-exchange')
      .query({ token: 'one-time', return: returnValue });

    expect(res.status).toBe(303);
    expect(res.headers.location).toBe('/');
  });

  test('keeps the existing JSON exchange behavior when no return is supplied', async () => {
    const { app } = createApp();
    const res = await request(app)
      .get('/api/v1/auth/sso-exchange')
      .query({ token: 'one-time' });

    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({ success: true, authenticated: true });
  });

  test('does not set a cookie or redirect for an invalid token', async () => {
    const { app, session } = createApp({ redeem: false });
    const res = await request(app)
      .get('/api/v1/auth/sso-exchange')
      .query({ token: 'bad', return: '/settings' });

    expect(res.status).toBe(401);
    expect(res.headers['set-cookie']).toBeUndefined();
    expect(session.setCookieHostOnly).not.toHaveBeenCalled();
  });
});

describe('existing-session SSO handoff', () => {
  test('mints a handoff token without asking for TOTP again', async () => {
    const { app, session } = createApp();
    const res = await request(app)
      .get('/api/v1/auth/sso-handoff?serviceId=plex')
      .set('Cookie', 'dashcaddy_session=valid-session');

    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
    expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
    expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
  });

  test('refuses to mint a handoff token without a valid session', async () => {
    const { app, session } = createApp({ valid: false });
    const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');

    expect(res.status).toBe(401);
    expect(session.createHandoffToken).not.toHaveBeenCalled();
  });

  test('completes the full mint, exchange, cookie, redirect lifecycle', async () => {
    const issued = new Set(['fresh-sso-handoff-token']);
    const redeemOnce = (token) => issued.delete(token);
    const { app } = createApp({ redeem: redeemOnce });

    const mint = await request(app)
      .get('/api/v1/auth/sso-handoff?serviceId=plex')
      .set('Cookie', 'dashcaddy_session=valid-session');
    const exchange = await request(app)
      .get('/api/v1/auth/sso-exchange')
      .query({ token: mint.body.ssoToken, return: '/web/' });

    expect(exchange.status).toBe(303);
    expect(exchange.headers.location).toBe('/web/');
    expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
    expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);

    const replay = await request(app)
      .get('/api/v1/auth/sso-exchange')
      .query({ token: mint.body.ssoToken, return: '/web/' });
    expect(replay.status).toBe(401);
  });
});

describe('encrypted-vault credential onboarding', () => {
  test('app-token identifies missing credentials as a form requirement', async () => {
    const { app } = createApp();
    const res = await request(app)
      .get('/api/v1/auth/app-token/plex')
      .set('Cookie', 'dashcaddy_session=valid-session');

    expect(res.status).toBe(428);
    expect(res.body).toMatchObject({
      success: false,
      credentialsRequired: true,
      serviceId: 'plex',
    });
  });

  test('service login page sends missing credentials to the encrypted vault form', async () => {
    const { app } = createApp();
    const res = await request(app).get('/api/v1/auth/login-page?service=plex');

    expect(res.status).toBe(200);
    expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}");
    expect(res.text).toContain("dashboardOrigin+'?credentials='");
  });

  test('service login page derives the vault origin from trusted dashboard config', async () => {
    const { app } = createApp({ dashboardHost: 'dashboard.home' });
    const res = await request(app).get('/api/v1/auth/login-page?service=plex');

    expect(res.status).toBe(200);
    expect(res.text).toContain('dashboardOrigin="https://dashboard.home"');
  });

  test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => {
    const issued = new Set(['fresh-sso-handoff-token']);
    const { app } = createApp({ redeem: (token) => issued.delete(token) });
    const mint = await request(app)
      .get('/api/v1/auth/sso-handoff?serviceId=plex')
      .set('Cookie', 'dashcaddy_session=valid-session');

    const vault = loadCredentialVaultHandoff();
    const target = new URL(vault.buildHandoffTarget(
      'https://plex.sami/web/?direct=1#home',
      mint.body.ssoToken,
      'plex',
    ));
    // The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API
    // route while preserving the token and relative return query.
    const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search);

    expect(target.pathname).toBe('/dashcaddy-sso');
    expect(exchange.status).toBe(303);
    expect(exchange.headers.location).toBe('/web/?direct=1#home');
    expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
    expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
  });
});
