// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import {
  HttpStatus,
  HUBJECT_DEFAULT_BASEURL,
  HUBJECT_DEFAULT_CLIENTID,
  HUBJECT_DEFAULT_CLIENTSECRET,
  HUBJECT_DEFAULT_TOKENURL,
  SystemConfig,
} from '@citrineos/base';
import { faker } from '@faker-js/faker';
import type { ILogObj } from 'tslog';
import { Logger } from 'tslog';
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { Hubject } from '../../../certificate/client/hubject.js';
import { MemoryCache } from '../../../index.js';
import {
  aValidRootCertificates,
  aValidSignedContractData,
  HUBJECT_DEFAULT_AUTH_TOKEN,
} from '../../providers/Hubject.js';

describe('Hubject', () => {
  const mockBaseURL = 'https://hubject.base.test';
  const mockTokenURL = 'https://hubject.token.test';
  const mockBearerToken = 'Bearer ' + HUBJECT_DEFAULT_AUTH_TOKEN;
  const mockClientID = 'test-client-id';
  const mockClientSecret = 'test-client-secret';

  let systemConfig: SystemConfig;
  let logger: Logger<ILogObj>;
  let cache: MemoryCache;
  let hubject: Hubject;

  beforeEach(() => {
    global.fetch = vi.fn();
    logger = new Logger<ILogObj>();
    cache = new MemoryCache();
    systemConfig = {
      util: {
        certificateAuthority: {
          v2gCA: {
            name: 'hubject',
            hubject: {
              baseUrl: mockBaseURL,
              tokenUrl: mockTokenURL,
              clientId: mockClientID,
              clientSecret: mockClientSecret,
            },
          },
        },
      },
    } as any;
    hubject = new Hubject(systemConfig, cache, logger);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('getAuthorizationToken', () => {
    beforeEach(async () => {
      // Clear cache before each token test
      await cache.remove('HUBJECT_AUTH_TOKEN', 'hubject');
      await cache.remove('HUBJECT_AUTH_TOKEN_LOCK', 'hubject');
    });

    it('should return default Bearer token when using default credentials', async () => {
      (fetch as Mock).mockResolvedValueOnce({
        ok: true,
        json: async () => ({ data: `Bearer ${HUBJECT_DEFAULT_AUTH_TOKEN}` }),
      });

      // Create a new instance with default credentials
      const defaultConfig: SystemConfig = {
        util: {
          certificateAuthority: {
            v2gCA: {
              name: 'hubject',
              hubject: {
                baseUrl: HUBJECT_DEFAULT_BASEURL,
                tokenUrl: HUBJECT_DEFAULT_TOKENURL,
                clientId: HUBJECT_DEFAULT_CLIENTID,
                clientSecret: HUBJECT_DEFAULT_CLIENTSECRET,
              },
            },
          },
        },
      } as SystemConfig;

      const hubjectWithDefaults = new Hubject(defaultConfig, cache, logger);

      const token = await (hubjectWithDefaults as any)._getAuthorizationToken();

      expect(token).toBe(`Bearer ${HUBJECT_DEFAULT_AUTH_TOKEN}`);
    });

    it('should successfully retrieve an authorization token', async () => {
      const mockToken = 'mock-access-token-12345';
      (fetch as Mock).mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          access_token: mockToken,
          scope: 'rcpservice pcpservice pkigateway ccpservice cpservice webhooks',
          expires_in: 86400,
          token_type: 'Bearer',
        }),
      });

      const token = await (hubject as any)._getAuthorizationToken(mockTokenURL);

      expect(token).toBe(`Bearer ${mockToken}`);
      expect(fetch).toHaveBeenCalledWith(
        mockTokenURL,
        expect.objectContaining({
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
        }),
      );
    });

    it('should throw error when response is not ok', async () => {
      (fetch as Mock).mockResolvedValueOnce({
        ok: false,
        status: 500,
        text: async () => 'Internal Server Error',
      });

      await expect((hubject as any)._getAuthorizationToken(mockTokenURL)).rejects.toThrow(
        'Get token response is unexpected: 500: Internal Server Error',
      );
    });

    it('should throw error when access_token is missing from response', async () => {
      (fetch as Mock).mockResolvedValueOnce({
        ok: true,
        json: async () => ({}),
      });

      await expect((hubject as any)._getAuthorizationToken(mockTokenURL)).rejects.toThrow(
        'Error while making call for hubject auth token',
      );
    });

    it('should return cached token if available', async () => {
      const cachedToken = 'Bearer cached-token';
      await cache.set('HUBJECT_AUTH_TOKEN', cachedToken, 'hubject', 3600);

      const token = await (hubject as any)._getAuthorizationToken();

      expect(token).toBe(cachedToken);
      expect(fetch).not.toHaveBeenCalled();
    });

    it('should cache token after fetching', async () => {
      const mockToken = 'new-access-token';
      (fetch as Mock).mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          access_token: mockToken,
          expires_in: 3600,
        }),
      });

      await (hubject as any)._getAuthorizationToken();

      const cachedToken = await cache.get<string>('HUBJECT_AUTH_TOKEN', 'hubject');
      expect(cachedToken).toBe(`Bearer ${mockToken}`);
    });

    it('should wait and retry when lock is held by another instance', async () => {
      // Set lock to simulate another instance fetching
      await cache.setIfNotExist('HUBJECT_AUTH_TOKEN_LOCK', 'locked', 'hubject', 30);

      const mockToken = 'delayed-token';
      // After first retry, cache will have the token (simulating another instance finished)
      setTimeout(async () => {
        await cache.set('HUBJECT_AUTH_TOKEN', `Bearer ${mockToken}`, 'hubject', 3600);
      }, 500);

      const token = await (hubject as any)._getAuthorizationToken();

      expect(token).toBe(`Bearer ${mockToken}`);
      expect(fetch).not.toHaveBeenCalled();
    });

    it('should throw error after max retries exceeded', async () => {
      // Hold the lock indefinitely
      await cache.setIfNotExist('HUBJECT_AUTH_TOKEN_LOCK', 'locked', 'hubject', 300);

      await expect((hubject as any)._getAuthorizationToken()).rejects.toThrow(
        /Max retries \(10\) exceeded/,
      );
    }, 60000);
  });

  describe('403 retry logic', () => {
    it.each(['getSignedCertificate', 'getCACertificates'])(
      'should retry on 403 and succeed with new token',
      async (method) => {
        const getAuthSpy = vi.spyOn(hubject as any, '_getAuthorizationToken');
        const removeSpy = vi.spyOn(cache, 'remove');
        await cache.set('HUBJECT_AUTH_TOKEN', 'expired', 'hubject', 3600);
        const mockResponse = JSON.stringify({
          some: 'data',
        });

        (fetch as Mock)
          .mockResolvedValueOnce({
            ok: false,
            status: HttpStatus.FORBIDDEN,
            text: async () => 'Forbidden',
          })
          .mockResolvedValueOnce({
            ok: true,
            status: HttpStatus.OK,
            json: async () => ({
              access_token: mockBearerToken,
              expires_in: 3600,
            }),
          })
          .mockResolvedValueOnce({
            ok: true,
            status: HttpStatus.OK,
            text: async () => mockResponse,
          });

        const result = await (hubject as any)[method]('arg1');

        expect(result).toBe(mockResponse);
        expect(removeSpy).toHaveBeenCalledWith('HUBJECT_AUTH_TOKEN', 'hubject');
        expect(getAuthSpy).toHaveBeenCalledTimes(2);
      },
    );

    it('getSignedContractData should retry on 403 and succeed with new token', async () => {
      const getAuthSpy = vi.spyOn(hubject as any, '_getAuthorizationToken');
      const removeSpy = vi.spyOn(cache, 'remove');
      await cache.set('HUBJECT_AUTH_TOKEN', 'expired', 'hubject', 3600);
      const expectedValue = 'test';
      const mockResponse = JSON.stringify({
        CCPResponse: {
          emaidContent: [
            {
              messageDef: {
                certificateInstallationRes: expectedValue,
              },
            },
          ],
        },
      });

      (fetch as Mock)
        .mockResolvedValueOnce({
          ok: false,
          status: HttpStatus.FORBIDDEN,
          text: async () => 'Forbidden',
        })
        .mockResolvedValueOnce({
          ok: true,
          status: HttpStatus.OK,
          json: async () => ({
            access_token: mockBearerToken,
            expires_in: 3600,
          }),
        })
        .mockResolvedValueOnce({
          ok: true,
          status: HttpStatus.OK,
          text: async () => mockResponse,
        });

      const result = await hubject.getSignedContractData('arg1', 'arg2');

      expect(result).toBe(expectedValue);
      expect(removeSpy).toHaveBeenCalledWith('HUBJECT_AUTH_TOKEN', 'hubject');
      expect(getAuthSpy).toHaveBeenCalledTimes(2);
    });

    it('getRootCertificates should retry on 403 and succeed with new token', async () => {
      const getAuthSpy = vi.spyOn(hubject as any, '_getAuthorizationToken');
      const removeSpy = vi.spyOn(cache, 'remove');
      await cache.set('HUBJECT_AUTH_TOKEN', 'expired', 'hubject', 3600);
      const expectedValue = 'test-cert';
      const mockResponse = JSON.stringify({
        RootCertificateCollection: {
          rootCertificates: [
            {
              caCertificate: expectedValue,
            },
          ],
        },
      });

      (fetch as Mock)
        .mockResolvedValueOnce({
          ok: false,
          status: HttpStatus.FORBIDDEN,
          text: async () => 'Forbidden',
        })
        .mockResolvedValueOnce({
          ok: true,
          status: HttpStatus.OK,
          json: async () => ({
            access_token: mockBearerToken,
            expires_in: 3600,
          }),
        })
        .mockResolvedValueOnce({
          ok: true,
          status: HttpStatus.OK,
          text: async () => mockResponse,
        });

      const result = await hubject.getRootCertificates();

      expect(result).toStrictEqual([
        `-----BEGIN CERTIFICATE-----\n${expectedValue}\n-----END CERTIFICATE-----\n`,
      ]);
      expect(removeSpy).toHaveBeenCalledWith('HUBJECT_AUTH_TOKEN', 'hubject');
      expect(getAuthSpy).toHaveBeenCalledTimes(2);
    });

    it.each([
      'getSignedCertificate',
      'getCACertificates',
      'getSignedContractData',
      'getRootCertificates',
    ])('should throw error if retry also fails', async (method) => {
      (fetch as Mock)
        .mockResolvedValueOnce({
          ok: false,
          status: HttpStatus.FORBIDDEN,
          text: async () => 'Forbidden',
        })
        .mockResolvedValueOnce({
          ok: false,
          status: HttpStatus.FORBIDDEN,
          text: async () => 'Still Forbidden',
        });

      await expect((hubject as any)[method]('arg1', 'arg2')).rejects.toThrow(
        'Get token response is unexpected: 403: Forbidden',
      );
    });

    it.each([
      'getSignedCertificate',
      'getCACertificates',
      'getSignedContractData',
      'getRootCertificates',
    ])('should throw error if retry also fails', async (method) => {
      (fetch as Mock)
        .mockResolvedValueOnce({
          ok: false,
          status: HttpStatus.UNAUTHORIZED,
          text: async () => 'Unauthorized',
        })
        .mockResolvedValueOnce({
          ok: false,
          status: HttpStatus.UNAUTHORIZED,
          text: async () => 'Still Unauthorized',
        });

      await expect((hubject as any)[method]('arg1', 'arg2')).rejects.toThrow(
        'Get token response is unexpected: 401: Unauthorized',
      );
    });
  });

  describe('authorized endpoints', () => {
    beforeEach(() => {
      // make authorization work for these endpoints
      vi.spyOn(hubject as any, '_getAuthorizationToken').mockResolvedValue(mockBearerToken);
    });

    describe('getSignedContractData', () => {
      it('successes', async () => {
        (fetch as Mock).mockReturnValueOnce(
          Promise.resolve({
            status: HttpStatus.OK,
            text: async () => JSON.stringify(aValidSignedContractData()),
          }),
        );

        const givenXsdMsgDefNamespace = faker.lorem.word();
        const givenCertificateInstallationReq = faker.lorem.word();
        const actualResult = await hubject.getSignedContractData(
          givenXsdMsgDefNamespace,
          givenCertificateInstallationReq,
        );

        // check return from getSignedContractData() method
        const expectedResult =
          ' ';
        expect(actualResult).toBe(expectedResult);
        // check args passed to fetch
        const expectedUrl = `${mockBaseURL}/v1/ccp/signedContractData`;
        const expectedRequestInit: RequestInit = {
          method: 'POST',
          headers: {
            Accept: 'application/json',
            Authorization: mockBearerToken,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            certificateInstallationReq: givenCertificateInstallationReq,
            xsdMsgDefNamespace: givenXsdMsgDefNamespace,
          }),
        };
        expect(fetch).toHaveBeenLastCalledWith(expectedUrl, expectedRequestInit);
      });

      it('fails when server returns 404', async () => {
        const responseBody = JSON.stringify({
          CCPResponse: {
            emaidContent: [
              {
                messageDef: {
                  certificateInstallationRes: 'gJgCNWnz+qh6tzbBAMIagnkDGyuToktzm6MLY2EDm6',
                },
              },
            ],
          },
        });
        (fetch as Mock).mockReturnValueOnce(
          Promise.resolve({
            status: 404,
            text: async () => responseBody,
          }),
        );

        const givenXsdMsgDefNamespace = faker.lorem.word();
        const givenCertificateInstallationReq = faker.lorem.word();
        await expect(
          hubject.getSignedContractData(givenXsdMsgDefNamespace, givenCertificateInstallationReq),
        ).rejects.toThrow(`Get signed contract data response is unexpected: 404: ${responseBody}`);
      });
    });

    describe('getRootCertificates', () => {
      it('successes', async () => {
        (fetch as Mock).mockReturnValueOnce(
          Promise.resolve({
            status: HttpStatus.OK,
            text: async () => JSON.stringify(aValidRootCertificates()),
          }),
        );

        const actualResult = await hubject.getRootCertificates();

        // check return from getRootCertificates() method
        const expectedResult = [
          '-----BEGIN CERTIFICATE-----\nMIICFjCCAbygAwIBAgIQbIqT2zas5F2twNToU49ArDAKBggqhkjOPQQDAjBYMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMSHViamVjdCBJbmMuMRMwEQYKCZImiZPyLGQBGRYDVjJHMR0wGwYDVQQDExRVUyBWMkcgUm9vdCBDQSBRQSBHMjAgFw0yMDA1MTkyMTU5NTlaGA8yMDYwMDUxOTIxNTk1OVowWDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDEh1YmplY3QgSW5jLjETMBEGCgmSJomT8ixkARkWA1YyRzEdMBsGA1UEAxMUVVMgVjJHIFJvb3QgQ0EgUUEgRzIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR2/KiLLZNcg4l5jANoMMotv5eY252KPiMegm8N9gOdj3/vskoamOAPD18ot+jczZRwPrxgRhS0Jq4PwlIdCWibo2YwZDAPBgNVHRMBAf8EBTADAQH/MBEGA1UdDgQKBAhK2xerpZDAaDAZBgNVHSAEEjAQMA4GDCsGAQQBg64NAQIBAjATBgNVHSMEDDAKgAhK2xerpZDAaDAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIhAMUGxn3tBpgOZ/JnaKgFinRufQYRhoTtxFjbNw5d4OebAiBZrbKEoMHbBiWz7dSfwGfmzMQbgqPoGA8SSpA1a053Gg==\n-----END CERTIFICATE-----\n',
          '-----BEGIN CERTIFICATE-----\nMIIBqjCCAVGgAwIBAgIQVxCaWc+R9k7esNEQSBW22jAKBggqhkjOPQQDAjA9MQswCQYDVQQGEwJERTEOMAwGA1UEChMFTmV4dXMxHjAcBgNVBAMTFUh1YmplY3QgUm9vdCBJTlRFUk5BTDAeFw0xOTA3MjUxMDQ3NThaFw0yNDA3MjUxMDQ3NThaMD0xCzAJBgNVBAYTAkRFMQ4wDAYDVQQKEwVOZXh1czEeMBwGA1UEAxMVSHViamVjdCBSb290IElOVEVSTkFMMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj/frqHOQRfacyKNNDreXJTbVjrjBLart/OaNB8pV9HzWWhHdG3qkwYMCk7ip8Y/4hvZDAPCcTKNCAqjSqMg66qMzMDEwDwYDVR0TAQH/BAUwAwEB/zARBgNVHQ4ECgQIRD+HP5qgLzowCwYDVR0PBAQDAgEGMAoGCCqGSM49BAMCA0cAMEQCIHOz3U0fewDP/N7o/pFmDtoz12tyXk0V2IK5kIQN9LCnAiAG7bbNzTYFy2cGIGr8uCX9i8kPz43IWYhyGwI1vsUa+w==\n-----END CERTIFICATE-----\n',
        ];
        expect(actualResult).toStrictEqual(expectedResult);
        // check args passed to fetch
        const expectedUrl = `${mockBaseURL}/v1/root/rootCerts`;
        const expectedRequestInit: RequestInit = {
          method: 'GET',
          headers: {
            Accept: 'application/json',
            Authorization: mockBearerToken,
          },
        };
        expect(fetch).toHaveBeenLastCalledWith(expectedUrl, expectedRequestInit);
      });
    });

    describe('getSignedCertificate', () => {
      it('fails due to internal server error', async () => {
        (fetch as Mock).mockReturnValueOnce(
          Promise.resolve({
            status: 500,
            text: async () => 'Internal Server Error',
          }),
        );

        const givenCSRString = faker.lorem.word();
        await expect(() => hubject.getSignedCertificate(givenCSRString)).rejects.toThrow(
          'Get signed certificate response is unexpected: 500: Internal Server Error',
        );

        // check args passed to fetch
        const expectedUrl = `${mockBaseURL}/.well-known/cpo/simpleenroll`;
        const expectedRequestInit: RequestInit = {
          method: 'POST',
          headers: {
            Accept: 'application/pkcs10',
            Authorization: mockBearerToken,
            'Content-Type': 'application/pkcs10',
          },
          body: givenCSRString,
        };
        expect(fetch).toHaveBeenLastCalledWith(expectedUrl, expectedRequestInit);
      });
    });

    describe('getCACertificates', () => {
      it('fails due to internal server error', async () => {
        (fetch as Mock).mockReturnValueOnce(
          Promise.resolve({
            status: 500,
            text: async () => 'Internal Server Error',
          }),
        );

        await expect(() => hubject.getCACertificates()).rejects.toThrow(
          'Get CA certificates response is unexpected: 500: Internal Server Error',
        );

        // check args passed to fetch
        const expectedUrl = `${mockBaseURL}/.well-known/cpo/cacerts`;
        const expectedRequestInit: RequestInit = {
          method: 'GET',
          headers: {
            Accept: 'application/pkcs10, application/pkcs7',
            Authorization: mockBearerToken,
            'Content-Transfer-Encoding': 'application/pkcs10',
          },
        };
        expect(fetch).toHaveBeenLastCalledWith(expectedUrl, expectedRequestInit);
      });
    });
  });
});
