import { expect } from 'chai';
import request from 'supertest';
import nock from 'nock';
import type { Express, Request, Response } from 'express';
import { DM } from '../../../src/bin';
import OpenIDConnect from '../../../src/plugins/auth/openidconnect';
import DmPlugin from '../../../src/abstract/plugin';

// Simple test plugin to verify auth flow
class TestProtectedResource extends DmPlugin {
  name = 'testProtectedResource';

  api(app: Express): void {
    app.get('/api/protected', (req, res) => {
      // @ts-expect-error req.user is set by OpenID Connect
      if (req.user) {
        res.json({
          message: 'Access granted',
          // @ts-expect-error req.user is set by OpenID Connect
          user: req.user,
        });
      } else {
        res.status(401).json({ error: 'Unauthorized' });
      }
    });
  }
}

describe('OpenID Connect Plugin', () => {
  describe('Configuration Validation', () => {
    it('should throw error if oidc_server is missing', async () => {
      const dm = new DM();
      await dm.ready;

      // Missing oidc_server
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      expect(() => new OpenIDConnect(dm)).to.throw(
        'Missing config parameter oidc_server'
      );
    });

    it('should throw error if oidc_client_id is missing', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      // Missing oidc_client_id
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      expect(() => new OpenIDConnect(dm)).to.throw(
        'Missing config parameter oidc_client_id'
      );
    });

    it('should throw error if oidc_client_secret is missing', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      dm.config.oidc_client_id = 'test-client-id';
      // Missing oidc_client_secret
      dm.config.base_url = 'http://localhost:3000';

      expect(() => new OpenIDConnect(dm)).to.throw(
        'Missing config parameter oidc_client_secret'
      );
    });

    it('should throw error if base_url is missing', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      // Missing base_url

      expect(() => new OpenIDConnect(dm)).to.throw(
        'Missing config parameter base_url'
      );
    });

    it('should create plugin with all required config parameters', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      const plugin = new OpenIDConnect(dm);

      expect(plugin).to.be.an.instanceOf(OpenIDConnect);
      expect(plugin.name).to.equal('openidconnect');
      expect(plugin.roles).to.deep.equal(['auth']);
    });
  });

  describe('Plugin Properties', () => {
    let dm: DM;
    let plugin: OpenIDConnect;

    before(async () => {
      dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      plugin = new OpenIDConnect(dm);
    });

    it('should have correct plugin name', () => {
      expect(plugin.name).to.equal('openidconnect');
    });

    it('should have auth role', () => {
      expect(plugin.roles).to.include('auth');
      expect(plugin.roles.length).to.equal(1);
    });

    it('should have api method', () => {
      expect(plugin.api).to.be.a('function');
    });

    it('should have authMethod', () => {
      expect(plugin.authMethod).to.be.a('function');
    });
  });

  describe('Hook Integration', () => {
    it('should register with DM hooks system', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      const plugin = new OpenIDConnect(dm);
      const result = await dm.registerPlugin('openidconnect', plugin);

      // Verify plugin registration completed without errors
      expect(result).to.not.throw;
      expect(plugin.name).to.equal('openidconnect');
    });

    it('should support beforeAuth and afterAuth hooks', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      const plugin = new OpenIDConnect(dm);
      await dm.registerPlugin('openidconnect', plugin);

      // The plugin uses beforeAuth and afterAuth hooks
      // Verify plugin was registered and has correct role
      expect(plugin.roles).to.include('auth');
      expect(plugin.name).to.equal('openidconnect');
    });
  });

  describe('Configuration Structure', () => {
    it('should pass correct config to express-openid-connect', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'https://auth.example.com';
      dm.config.oidc_client_id = 'my-client-id';
      dm.config.oidc_client_secret = 'my-client-secret';
      dm.config.base_url = 'https://app.example.com';

      const plugin = new OpenIDConnect(dm);

      // Verify plugin stores reference to server
      expect(plugin.server).to.equal(dm);
      expect(plugin.config.oidc_server).to.equal('https://auth.example.com');
      expect(plugin.config.oidc_client_id).to.equal('my-client-id');
      expect(plugin.config.oidc_client_secret).to.equal('my-client-secret');
      expect(plugin.config.base_url).to.equal('https://app.example.com');
    });
  });

  describe('Multiple Instances', () => {
    it('should allow creating multiple instances with different configs', async () => {
      const dm1 = new DM();
      await dm1.ready;

      dm1.config.oidc_server = 'http://auth1.example.com';
      dm1.config.oidc_client_id = 'client1';
      dm1.config.oidc_client_secret = 'secret1';
      dm1.config.base_url = 'http://app1.example.com';

      const plugin1 = new OpenIDConnect(dm1);

      const dm2 = new DM();
      await dm2.ready;

      dm2.config.oidc_server = 'http://auth2.example.com';
      dm2.config.oidc_client_id = 'client2';
      dm2.config.oidc_client_secret = 'secret2';
      dm2.config.base_url = 'http://app2.example.com';

      const plugin2 = new OpenIDConnect(dm2);

      expect(plugin1.config.oidc_server).to.equal('http://auth1.example.com');
      expect(plugin2.config.oidc_server).to.equal('http://auth2.example.com');
    });
  });

  describe('Error Handling', () => {
    it('should handle undefined config values', async () => {
      const dm = new DM();
      await dm.ready;

      // All config values are undefined
      expect(() => new OpenIDConnect(dm)).to.throw('Missing config parameter');
    });

    it('should handle null config values', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = undefined;
      dm.config.oidc_client_id = undefined;
      dm.config.oidc_client_secret = undefined;
      dm.config.base_url = undefined;

      expect(() => new OpenIDConnect(dm)).to.throw('Missing config parameter');
    });

    it('should handle empty string config values', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = '';
      dm.config.oidc_client_id = 'client-id';
      dm.config.oidc_client_secret = 'secret';
      dm.config.base_url = 'http://localhost:3000';

      expect(() => new OpenIDConnect(dm)).to.throw(
        'Missing config parameter oidc_server'
      );
    });
  });

  describe('Plugin Registration Order', () => {
    it('should work when registered before other plugins', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      const oidcPlugin = new OpenIDConnect(dm);

      // Register OpenID Connect first
      const result = await dm.registerPlugin('openidconnect', oidcPlugin);

      expect(result).to.not.throw;
      expect(oidcPlugin.name).to.equal('openidconnect');
    });

    it('should work when registered after other plugins', async () => {
      const dm = new DM();
      await dm.ready;

      dm.config.oidc_server = 'http://localhost:8080';
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      const oidcPlugin = new OpenIDConnect(dm);

      // Register OpenID Connect after DM is ready
      const result = await dm.registerPlugin('openidconnect', oidcPlugin);

      expect(result).to.not.throw;
      expect(oidcPlugin.server).to.equal(dm);
    });
  });

  describe('OIDC Server Integration', () => {
    const oidcServer = 'http://auth.test.local';
    let app: Express;
    let dm: DM;

    beforeEach(async () => {
      // Mock OIDC discovery endpoint
      nock(oidcServer)
        .persist()
        .get('/.well-known/openid-configuration')
        .reply(200, {
          issuer: oidcServer,
          authorization_endpoint: `${oidcServer}/authorize`,
          token_endpoint: `${oidcServer}/token`,
          userinfo_endpoint: `${oidcServer}/userinfo`,
          jwks_uri: `${oidcServer}/jwks`,
          response_types_supported: ['code'],
          subject_types_supported: ['public'],
          id_token_signing_alg_values_supported: ['RS256'],
        });

      // Mock JWKS endpoint
      nock(oidcServer)
        .persist()
        .get('/jwks')
        .reply(200, {
          keys: [
            {
              kty: 'RSA',
              kid: 'test-key-id',
              use: 'sig',
              n: 'xGOr-H7A-PWp_4NWiCAHF0K_mH24-lJNHGsXpMB',
              e: 'AQAB',
            },
          ],
        });

      dm = new DM();
      await dm.ready;

      dm.config.oidc_server = oidcServer;
      dm.config.oidc_client_id = 'test-client-id';
      dm.config.oidc_client_secret = 'test-client-secret';
      dm.config.base_url = 'http://localhost:3000';

      const oidcPlugin = new OpenIDConnect(dm);
      const testPlugin = new TestProtectedResource(dm);

      await dm.registerPlugin('openidconnect', oidcPlugin);
      await dm.registerPlugin('testProtectedResource', testPlugin);

      app = dm.app;
    });

    afterEach(() => {
      nock.cleanAll();
    });

    it('should initialize with OIDC discovery endpoint', async () => {
      // The plugin should query the discovery endpoint during initialization
      // This is handled by express-openid-connect
      const plugin = new OpenIDConnect(dm);
      expect(plugin).to.be.instanceOf(OpenIDConnect);
    });

    it('should configure authorization parameters correctly', async () => {
      const plugin = new OpenIDConnect(dm);

      // Verify the plugin has correct configuration
      expect(plugin.config.oidc_server).to.equal(oidcServer);
      expect(plugin.config.oidc_client_id).to.equal('test-client-id');
    });

    it('should handle token exchange with OIDC server', async () => {
      // Mock token endpoint
      const tokenScope = nock(oidcServer).post('/token').reply(200, {
        access_token: 'test-access-token',
        token_type: 'Bearer',
        expires_in: 3600,
        id_token: 'test-id-token',
      });

      // This test verifies that the configuration is correct
      // The actual token exchange is handled by express-openid-connect
      expect(tokenScope).to.not.be.undefined;
    });

    it('should handle userinfo endpoint responses', async () => {
      // Mock userinfo endpoint
      const userinfoScope = nock(oidcServer)
        .get('/userinfo')
        .matchHeader('Authorization', /Bearer .+/)
        .reply(200, {
          sub: 'user-123',
          name: 'Test User',
          email: 'test@example.com',
          email_verified: true,
        });

      // Verify the mock is set up
      expect(userinfoScope).to.not.be.undefined;
    });

    it('should handle OIDC server errors gracefully', async () => {
      // Mock discovery endpoint failure
      nock.cleanAll();
      nock(oidcServer)
        .get('/.well-known/openid-configuration')
        .reply(500, 'Internal Server Error');

      // The plugin should handle this gracefully
      // express-openid-connect will retry or fail open depending on config
      const plugin = new OpenIDConnect(dm);
      expect(plugin).to.be.instanceOf(OpenIDConnect);
    });

    it('should handle invalid token responses', async () => {
      // Mock token endpoint with invalid response
      nock(oidcServer).post('/token').reply(401, {
        error: 'invalid_client',
        error_description: 'Client authentication failed',
      });

      // The plugin should handle this via express-openid-connect
      const plugin = new OpenIDConnect(dm);
      expect(plugin).to.be.instanceOf(OpenIDConnect);
    });

    it('should handle JWKS refresh', async () => {
      // Mock JWKS endpoint with new keys
      const jwksScope = nock(oidcServer)
        .get('/jwks')
        .reply(200, {
          keys: [
            {
              kty: 'RSA',
              kid: 'new-key-id',
              use: 'sig',
              n: 'yHPs-I8B-QXq_5OXjDBIG1L_nI35-mKOIHtYqNMC',
              e: 'AQAB',
            },
          ],
        });

      // Verify the mock is set up for JWKS refresh
      expect(jwksScope).to.not.be.undefined;
    });

    it('should validate required scopes', () => {
      const plugin = new OpenIDConnect(dm);

      // The plugin requests 'openid profile email' scopes
      // Verify configuration is set up correctly
      expect(plugin.config.oidc_server).to.equal(oidcServer);
    });

    it('should handle OAuth state parameter for CSRF protection', async () => {
      let capturedState = '';

      // Mock authorization endpoint that captures the state
      nock(oidcServer)
        .get('/authorize')
        .query(true) // Match any query parameters
        .reply(uri => {
          const url = new URL(uri, oidcServer);
          capturedState = url.searchParams.get('state') || '';

          // Return redirect with state and code
          return [
            302,
            '',
            {
              Location: `http://localhost:3000/callback?code=test-code&state=${capturedState}`,
            },
          ];
        });

      // Mock token endpoint that validates the code
      nock(oidcServer)
        .post('/token', body => {
          // Verify the token request includes the authorization code
          return (
            typeof body === 'object' &&
            body !== null &&
            'code' in body &&
            body.code === 'test-code'
          );
        })
        .reply(200, {
          access_token: 'test-access-token',
          token_type: 'Bearer',
          expires_in: 3600,
          id_token: 'test-id-token',
        });

      // Mock userinfo endpoint
      nock(oidcServer)
        .get('/userinfo')
        .matchHeader('Authorization', 'Bearer test-access-token')
        .reply(200, {
          sub: 'user-456',
          name: 'Test User',
          email: 'test@example.com',
        });

      // The state parameter should be generated and verified by express-openid-connect
      const plugin = new OpenIDConnect(dm);
      expect(plugin).to.be.instanceOf(OpenIDConnect);
    });

    it('should reject callback with mismatched state parameter', async () => {
      // Mock callback endpoint with wrong state
      nock(oidcServer).post('/token').reply(400, {
        error: 'invalid_request',
        error_description: 'State parameter mismatch',
      });

      // The plugin/express-openid-connect should reject this
      const plugin = new OpenIDConnect(dm);
      expect(plugin).to.be.instanceOf(OpenIDConnect);
    });

    it('should handle authorization code flow with state', async () => {
      const testState = 'random-csrf-state-value';
      const authCode = 'auth-code-12345';

      // Mock the complete authorization code flow
      nock(oidcServer)
        .get('/authorize')
        .query(query => {
          // Verify state is present in authorization request
          return query.state !== undefined;
        })
        .reply(302, '', {
          Location: `http://localhost:3000/callback?code=${authCode}&state=${testState}`,
        });

      nock(oidcServer)
        .post('/token', body => {
          return (
            typeof body === 'object' &&
            body !== null &&
            'code' in body &&
            body.code === authCode
          );
        })
        .reply(200, {
          access_token: 'access-token-xyz',
          token_type: 'Bearer',
          expires_in: 3600,
          id_token: 'eyJ.test.token',
          refresh_token: 'refresh-token-abc',
        });

      const plugin = new OpenIDConnect(dm);
      expect(plugin).to.be.instanceOf(OpenIDConnect);
    });
  });

  describe('Hook Execution', () => {
    let dm: DM;
    let beforeAuthCalled = false;
    let afterAuthCalled = false;

    beforeEach(async () => {
      beforeAuthCalled = false;
      afterAuthCalled = false;

      dm = new DM();
      await dm.ready;

      // Mock OIDC server
      const oidcServer = 'http://auth.test.local';
      nock(oidcServer)
        .persist()
        .get('/.well-known/openid-configuration')
        .reply(200, {
          issuer: oidcServer,
          authorization_endpoint: `${oidcServer}/authorize`,
          token_endpoint: `${oidcServer}/token`,
          userinfo_endpoint: `${oidcServer}/userinfo`,
          jwks_uri: `${oidcServer}/jwks`,
        });

      nock(oidcServer).persist().get('/jwks').reply(200, { keys: [] });

      dm.config.oidc_server = oidcServer;
      dm.config.oidc_client_id = 'test-client';
      dm.config.oidc_client_secret = 'test-secret';
      dm.config.base_url = 'http://localhost:3000';

      // Add hooks
      dm.hooks.beforeAuth = [
        async (req: Request, res: Response) => {
          beforeAuthCalled = true;
          return [req, res];
        },
      ];

      dm.hooks.afterAuth = [
        async (req: Request, res: Response) => {
          afterAuthCalled = true;
          return [req, res];
        },
      ];
    });

    afterEach(() => {
      nock.cleanAll();
    });

    it('should call beforeAuth and afterAuth hooks during authentication', async () => {
      const oidcPlugin = new OpenIDConnect(dm);
      await dm.registerPlugin('openidconnect', oidcPlugin);

      // Verify hooks are available
      expect(dm.hooks.beforeAuth).to.be.an('array');
      expect(dm.hooks.afterAuth).to.be.an('array');
    });

    it('should handle hook errors gracefully', async () => {
      dm.hooks.beforeAuth = [
        async () => {
          throw new Error('Hook error');
        },
      ];

      const oidcPlugin = new OpenIDConnect(dm);
      const result = await dm.registerPlugin('openidconnect', oidcPlugin);

      // Plugin should register despite hook errors
      expect(result).to.not.throw;
      expect(oidcPlugin.name).to.equal('openidconnect');
    });
  });
});
