const crypto = require('crypto');

// Mock crypto-utils to provide a predictable signing key
const mockFixedKey = Buffer.alloc(32, 'test-key-material');
jest.mock('../src/security/crypto-utils', () => ({
  loadOrCreateKey: jest.fn(() => mockFixedKey),
}));

const {
  CSRF_TOKEN_LENGTH,
  CSRF_COOKIE_NAME,
  CSRF_HEADER_NAME,
  generateToken,
  signToken,
  parseCookie,
  csrfCookieMiddleware,
  csrfValidationMiddleware,
  renewCSRFToken
} = require('../src/security/csrf-protection');
const { createMockReqRes } = require('./helpers/test-utils');

describe('CSRF Protection', () => {

  describe('generateToken', () => {
    it('returns a base64url-encoded string', () => {
      const token = generateToken();
      expect(typeof token).toBe('string');
      expect(token.length).toBeGreaterThan(0);
      // base64url chars only
      expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
    });

    it('returns different values on each call', () => {
      const t1 = generateToken();
      const t2 = generateToken();
      expect(t1).not.toBe(t2);
    });

    it('has appropriate length for 32 bytes of randomness', () => {
      const token = generateToken();
      // 32 bytes = 43 base64url chars (no padding)
      expect(token.length).toBe(43);
    });
  });

  describe('signToken', () => {
    it('returns a base64url-encoded HMAC signature', () => {
      const sig = signToken('test-nonce');
      expect(typeof sig).toBe('string');
      expect(sig).toMatch(/^[A-Za-z0-9_-]+$/);
    });

    it('same nonce produces same signature (deterministic)', () => {
      const sig1 = signToken('my-nonce');
      const sig2 = signToken('my-nonce');
      expect(sig1).toBe(sig2);
    });

    it('different nonces produce different signatures', () => {
      const sig1 = signToken('nonce-a');
      const sig2 = signToken('nonce-b');
      expect(sig1).not.toBe(sig2);
    });
  });

  describe('parseCookie', () => {
    it('parses single cookie', () => {
      expect(parseCookie('name=value')).toEqual({ name: 'value' });
    });

    it('parses multiple cookies', () => {
      const result = parseCookie('a=1; b=2; c=3');
      expect(result).toEqual({ a: '1', b: '2', c: '3' });
    });

    it('handles cookies with = in value', () => {
      const result = parseCookie('token=abc=def=ghi');
      expect(result.token).toBe('abc=def=ghi');
    });

    it('returns empty object for null/undefined/empty input', () => {
      expect(parseCookie(null)).toEqual({});
      expect(parseCookie(undefined)).toEqual({});
      expect(parseCookie('')).toEqual({});
    });

    it('trims outer whitespace of each cookie pair', () => {
      const result = parseCookie('  name=value  ');
      expect(result['name']).toBe('value');
    });
  });

  describe('csrfCookieMiddleware', () => {
    it('generates new nonce and sets cookie when no existing cookie', () => {
      const { req, res, next } = createMockReqRes();
      req.headers.cookie = '';

      csrfCookieMiddleware(req, res, next);

      expect(req.csrfNonce).toBeDefined();
      expect(req.csrfToken).toBeDefined();
      expect(res.cookie).toHaveBeenCalledWith(
        CSRF_COOKIE_NAME,
        req.csrfNonce,
        expect.objectContaining({
          httpOnly: false,
          sameSite: 'strict',
          path: '/',
        })
      );
      expect(next).toHaveBeenCalled();
    });

    it('reuses existing nonce from cookie (no new Set-Cookie)', () => {
      const { req, res, next } = createMockReqRes();
      const existingNonce = 'existing-nonce-value';
      req.headers.cookie = `${CSRF_COOKIE_NAME}=${existingNonce}`;

      csrfCookieMiddleware(req, res, next);

      expect(req.csrfNonce).toBe(existingNonce);
      expect(res.cookie).not.toHaveBeenCalled(); // No new cookie set
      expect(next).toHaveBeenCalled();
    });

    it('sets req.csrfToken as HMAC signature of nonce', () => {
      const { req, res, next } = createMockReqRes();
      req.headers.cookie = `${CSRF_COOKIE_NAME}=my-nonce`;

      csrfCookieMiddleware(req, res, next);

      const expectedSig = signToken('my-nonce');
      expect(req.csrfToken).toBe(expectedSig);
    });
  });

  describe('csrfValidationMiddleware', () => {
    it('skips validation for GET requests', () => {
      const { req, res, next } = createMockReqRes({ method: 'GET' });
      csrfValidationMiddleware(req, res, next);
      expect(next).toHaveBeenCalled();
      expect(res.status).not.toHaveBeenCalled();
    });

    it('skips validation for HEAD requests', () => {
      const { req, res, next } = createMockReqRes({ method: 'HEAD' });
      csrfValidationMiddleware(req, res, next);
      expect(next).toHaveBeenCalled();
    });

    it('skips validation for OPTIONS requests', () => {
      const { req, res, next } = createMockReqRes({ method: 'OPTIONS' });
      csrfValidationMiddleware(req, res, next);
      expect(next).toHaveBeenCalled();
    });

    it('skips validation in test environment', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'test';
      const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services' });

      csrfValidationMiddleware(req, res, next);

      expect(next).toHaveBeenCalled();
      process.env.NODE_ENV = origEnv;
    });

    it('skips validation for excluded paths', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      // Mirrors src/security/csrf-protection.js excludedPaths. If you add
      // a new entry there, add it here too — the test guards against the
      // drift that previously kept /api/v1/health in the list long after
      // the route itself was deleted.
      const excludedPaths = [
        '/api/v1/totp/verify',
        '/api/v1/totp/verify-setup',
        '/api/v1/totp/setup',
        '/health',
        '/health/live',
        '/health/ready',
        '/healthz',
        '/readyz',
        '/api/v1/system/update-notify',
      ];
      for (const excludedPath of excludedPaths) {
        const { req, res, next } = createMockReqRes({ method: 'POST', path: excludedPath });
        csrfValidationMiddleware(req, res, next);
        expect(next).toHaveBeenCalled();
      }

      process.env.NODE_ENV = origEnv;
    });

    it('skips validation for auth gate paths', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const { req, res, next } = createMockReqRes({
        method: 'POST', path: '/api/v1/auth/gate/plex'
      });
      csrfValidationMiddleware(req, res, next);
      expect(next).toHaveBeenCalled();

      process.env.NODE_ENV = origEnv;
    });

    it('skips validation when x-api-key header present', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const { req, res, next } = createMockReqRes({
        method: 'POST', path: '/api/services',
        headers: { 'x-api-key': 'dk_abc_123' }
      });
      csrfValidationMiddleware(req, res, next);
      expect(next).toHaveBeenCalled();

      process.env.NODE_ENV = origEnv;
    });

    it('skips validation when Authorization Bearer header present', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const { req, res, next } = createMockReqRes({
        method: 'POST', path: '/api/services',
        headers: { authorization: 'Bearer some-jwt-token' }
      });
      csrfValidationMiddleware(req, res, next);
      expect(next).toHaveBeenCalled();

      process.env.NODE_ENV = origEnv;
    });

    it('returns 403 when CSRF cookie missing', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const { req, res, next } = createMockReqRes({
        method: 'POST', path: '/api/services',
        headers: { cookie: '' }
      });
      csrfValidationMiddleware(req, res, next);
      expect(res.status).toHaveBeenCalledWith(403);
      expect(res.json).toHaveBeenCalledWith(
        expect.objectContaining({ error: expect.stringContaining('DC-100') })
      );

      process.env.NODE_ENV = origEnv;
    });

    it('returns 403 when CSRF header missing', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const nonce = generateToken();
      const { req, res, next } = createMockReqRes({
        method: 'POST', path: '/api/services',
        headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` }
      });
      csrfValidationMiddleware(req, res, next);
      expect(res.status).toHaveBeenCalledWith(403);
      expect(res.json).toHaveBeenCalledWith(
        expect.objectContaining({ error: expect.stringContaining('DC-100') })
      );

      process.env.NODE_ENV = origEnv;
    });

    it('returns 403 when signature is invalid', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const nonce = generateToken();
      const { req, res, next } = createMockReqRes({
        method: 'POST', path: '/api/services',
        headers: {
          cookie: `${CSRF_COOKIE_NAME}=${nonce}`,
          'x-csrf-token': 'totally-wrong-signature'
        }
      });
      csrfValidationMiddleware(req, res, next);
      expect(res.status).toHaveBeenCalledWith(403);
      expect(res.json).toHaveBeenCalledWith(
        expect.objectContaining({ error: expect.stringContaining('DC-101') })
      );

      process.env.NODE_ENV = origEnv;
    });

    it('passes when cookie nonce and header signature match', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const nonce = generateToken();
      const signature = signToken(nonce);
      const { req, res, next } = createMockReqRes({
        method: 'POST', path: '/api/services',
        headers: {
          cookie: `${CSRF_COOKIE_NAME}=${nonce}`,
          'x-csrf-token': signature
        }
      });
      csrfValidationMiddleware(req, res, next);
      expect(next).toHaveBeenCalled();
      expect(res.status).not.toHaveBeenCalled();

      process.env.NODE_ENV = origEnv;
    });

    it('excludes /api/v1/ paths directly', () => {
      const origEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = 'production';

      const { req, res, next } = createMockReqRes({
        method: 'POST', path: '/api/v1/totp/verify'
      });
      csrfValidationMiddleware(req, res, next);
      expect(next).toHaveBeenCalled();

      process.env.NODE_ENV = origEnv;
    });
  });

  describe('renewCSRFToken', () => {
    it('generates new nonce and sets cookie', () => {
      const { res } = createMockReqRes();
      const token = renewCSRFToken(res, true);

      expect(typeof token).toBe('string');
      expect(res.cookie).toHaveBeenCalledWith(
        CSRF_COOKIE_NAME,
        expect.any(String),
        expect.objectContaining({
          httpOnly: false,
          secure: true,
          sameSite: 'strict',
          path: '/',
        })
      );
    });

    it('returns signed token', () => {
      const { res } = createMockReqRes();
      const token = renewCSRFToken(res, false);
      // Get the nonce that was set in the cookie
      const setCookieNonce = res.cookie.mock.calls[0][1];
      const expectedSig = signToken(setCookieNonce);
      expect(token).toBe(expectedSig);
    });
  });
});
