// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
import { IDeviceModelRepository, VariableAttribute } from '@citrineos/core';
import { DEFAULT_TENANT_ID, OCPP2_0_1 } from '@citrineos/base';
import { faker } from '@faker-js/faker';
import { aBasicAuthPasswordVariable } from '../../providers/VariableAttributeProvider.js';
import { BasicAuthenticationFilter } from '../../../index.js';
import { aRequestWithAuthorization, basicAuth } from '../../providers/IncomingMessageProvider.js';
import { anAuthenticationOptions } from '../../providers/AuthenticationOptionsProvider.js';
import { afterEach, beforeEach, describe, expect, it, Mocked, vi } from 'vitest';

type PasswordPair = {
  plaintext: string;
  hash: string;
};

describe('BasicAuthenticationFilter', () => {
  const password: PasswordPair = {
    plaintext: 'SEPtwLckb5QD5on0EXcCAmuQVmJ*bu3ZXmA:Clt3',
    hash: 'PBKDF2:1000:64:sha512:salt:8a9ee05c38e81af180a43e5a707cfd70bf3624400e7c986ab4b8ef9c7a7647cd17a3c0f1860ebe08f7173dc1414c13f5a25528b08e9facd16fa4d089b35ba808',
  };

  const anotherPassword: PasswordPair = {
    plaintext: '_Oec8yF4r1hH6ildo4yvM25:SU2hpL*jobDskYos',
    hash: 'PBKDF2:1000:64:sha512:salt:7f87adf79f3050b0639c079c30c6696efaa1a84b437907de7f6545aac15322ee29a2d9cd66d459d41de9af56b2834c2a7cd3c3f7fa897db3d8f94a3a1c147ca2',
  };

  let deviceModelRepository: Mocked<IDeviceModelRepository>;
  let filter: BasicAuthenticationFilter;

  beforeEach(() => {
    deviceModelRepository = {
      readAllByQuerystring: vi.fn(),
    } as unknown as Mocked<IDeviceModelRepository>;

    filter = new BasicAuthenticationFilter(deviceModelRepository);
  });

  afterEach(() => {
    deviceModelRepository.readAllByQuerystring.mockReset();
  });

  describe.each([1, 2])(`given %i security profile `, (securityProfile) => {
    const authenticationOptions = anAuthenticationOptions({
      securityProfile,
      allowUnknownChargingStations: faker.datatype.boolean(),
    });

    it.each([
      ['9a06661c-2332-4897-b0d4-2187671dbe7b', ' 9a06661c-2332-4897-b0d4-2187671dbe7b'],
      ['9a06661c-2332-4897-b0d4-2187671dbe7b', '9a06661c-2332-4897-b0d4-2187671dbe7b '],
      ['9a06661c-2332-4897-b0d4-2187671dbe7b', '9a06661c-2332-4897-b0d4-2187671dbe7bb'],
      ['9a06661c-2332-4897-b0d4-2187671dbe7b', '8a06661c-2332-4897-b0d4-2187671dbe7b'],
      ['9a06661c-2332-4897-b0d4-2187671dbe7b', 'bc2696f3-66d5-4027-9eae-be74c1e85fa7'],
    ])(
      'should reject when station identifier does not match username',
      async (ocppConnectionName, username) => {
        givenPassword(ocppConnectionName, password.hash);

        await expect(
          filter.authenticate(
            DEFAULT_TENANT_ID,
            ocppConnectionName,
            aRequestWithAuthorization(basicAuth(username, password.plaintext)),
            authenticationOptions,
          ),
        ).rejects.toThrow(`Unauthorized ${ocppConnectionName}`);
        expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
      },
    );

    it('should reject when missing Authorization header', async () => {
      const ocppConnectionName = faker.string.uuid().toString();

      await expect(
        filter.authenticate(
          DEFAULT_TENANT_ID,
          ocppConnectionName,
          aRequestWithAuthorization(undefined),
          authenticationOptions,
        ),
      ).rejects.toThrow('Auth header missing or incorrectly formatted');
      expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
    });

    it('should reject when Authorization header is empty', async () => {
      const ocppConnectionName = faker.string.uuid().toString();

      await expect(
        filter.authenticate(
          DEFAULT_TENANT_ID,
          ocppConnectionName,
          aRequestWithAuthorization(''),
          authenticationOptions,
        ),
      ).rejects.toThrow('Auth header missing or incorrectly formatted');
      expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
    });

    it('should reject when Authorization header is not Basic', async () => {
      const ocppConnectionName = faker.string.uuid().toString();

      await expect(
        filter.authenticate(
          DEFAULT_TENANT_ID,
          ocppConnectionName,
          aRequestWithAuthorization(
            `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImNwMDAxIiwiaWF0IjoxNTE2MjM5MDIyfQ.Y3LDdxSufp_2nOqUmBWTR5CyQ2eEBWPPzjRIJqc6bn8`,
          ),
          authenticationOptions,
        ),
      ).rejects.toThrow('Auth header missing or incorrectly formatted');
      expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
    });

    it('should reject when missing username', async () => {
      const ocppConnectionName = faker.string.uuid().toString();

      await expect(
        filter.authenticate(
          DEFAULT_TENANT_ID,
          ocppConnectionName,
          aRequestWithAuthorization(basicAuth('', password.plaintext)),
          authenticationOptions,
        ),
      ).rejects.toThrow('Auth header missing or incorrectly formatted');
      expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
    });

    it('should reject when missing password', async () => {
      const ocppConnectionName = faker.string.uuid().toString();

      await expect(
        filter.authenticate(
          DEFAULT_TENANT_ID,
          ocppConnectionName,
          aRequestWithAuthorization(basicAuth(ocppConnectionName, '')),
          authenticationOptions,
        ),
      ).rejects.toThrow('Auth header missing or incorrectly formatted');
      expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
    });

    it('should reject when missing username and password', async () => {
      const ocppConnectionName = faker.string.uuid().toString();

      await expect(
        filter.authenticate(
          DEFAULT_TENANT_ID,
          ocppConnectionName,
          aRequestWithAuthorization(basicAuth('', '')),
          authenticationOptions,
        ),
      ).rejects.toThrow('Auth header missing or incorrectly formatted');
      expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
    });

    it('should reject when password is not found for station', async () => {
      const ocppConnectionName = faker.string.uuid().toString();
      givenNoPassword();

      await expect(
        filter.authenticate(
          DEFAULT_TENANT_ID,
          ocppConnectionName,
          aRequestWithAuthorization(basicAuth(ocppConnectionName, password.plaintext)),
          authenticationOptions,
        ),
      ).rejects.toThrow(`Unauthorized ${ocppConnectionName}`);
      expect(deviceModelRepository.readAllByQuerystring).toHaveBeenCalledWith(DEFAULT_TENANT_ID, {
        tenantId: DEFAULT_TENANT_ID,
        ocppConnectionName: ocppConnectionName,
        component_name: 'SecurityCtrlr',
        variable_name: 'BasicAuthPassword',
        type: OCPP2_0_1.AttributeEnumType.Actual,
      });
    });

    it.each([
      {
        actualPassword: password.hash,
        authenticationPassword: ` `,
      },
      {
        actualPassword: password.hash,
        authenticationPassword: anotherPassword.plaintext,
      },
      {
        actualPassword: anotherPassword.hash,
        authenticationPassword: password.plaintext,
      },
      {
        actualPassword: password.hash,
        authenticationPassword: `${password.plaintext} `,
      },
      {
        actualPassword: password.hash,
        authenticationPassword: ` ${password.plaintext}`,
      },
      {
        actualPassword: password.hash,
        authenticationPassword: ` ${password.plaintext} `,
      },
    ])(
      'should reject when password does not match',
      async ({ actualPassword, authenticationPassword }) => {
        const ocppConnectionName = faker.string.uuid().toString();
        givenPassword(ocppConnectionName, actualPassword);

        await expect(
          filter.authenticate(
            DEFAULT_TENANT_ID,
            ocppConnectionName,
            aRequestWithAuthorization(basicAuth(ocppConnectionName, authenticationPassword)),
            authenticationOptions,
          ),
        ).rejects.toThrow(`Unauthorized ${ocppConnectionName}`);
      },
    );

    it.each([
      {
        actualPassword: password.hash,
        authenticationPassword: password.plaintext,
      },
      {
        actualPassword: anotherPassword.hash,
        authenticationPassword: anotherPassword.plaintext,
      },
    ])(
      'should do nothing when password matches',
      async ({ actualPassword, authenticationPassword }) => {
        const ocppConnectionName = faker.string.uuid().toString();
        givenPassword(ocppConnectionName, actualPassword);

        await expect(async () => {
          await filter.authenticate(
            DEFAULT_TENANT_ID,
            ocppConnectionName,
            aRequestWithAuthorization(basicAuth(ocppConnectionName, authenticationPassword)),
            authenticationOptions,
          );
        }).not.toThrow();
      },
    );
  });

  describe.each([0, 3])(`given %i security profile`, (securityProfile) => {
    const authenticationOptions = anAuthenticationOptions({
      securityProfile,
      allowUnknownChargingStations: faker.datatype.boolean(),
    });

    it('should do nothing when missing Authorization header', async () => {
      const ocppConnectionName = faker.string.uuid().toString();

      await filter.authenticate(
        DEFAULT_TENANT_ID,
        ocppConnectionName,
        aRequestWithAuthorization(undefined),
        authenticationOptions,
      );
      expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
    });

    it('should do nothing when Authorization header is present', async () => {
      const ocppConnectionName = faker.string.uuid().toString();

      await filter.authenticate(
        DEFAULT_TENANT_ID,
        ocppConnectionName,
        aRequestWithAuthorization(basicAuth(ocppConnectionName, password.plaintext)),
        authenticationOptions,
      );

      expect(deviceModelRepository.readAllByQuerystring).not.toHaveBeenCalled();
    });
  });

  function givenPassword(ocppConnectionName: string, passwordHash: string): void {
    const passwordVariable = aBasicAuthPasswordVariable({
      ocppConnectionName: ocppConnectionName,
      value: passwordHash,
    } as Partial<VariableAttribute>);

    deviceModelRepository.readAllByQuerystring.mockResolvedValue([passwordVariable]);
  }

  function givenNoPassword() {
    deviceModelRepository.readAllByQuerystring.mockResolvedValue([]);
  }
});
