import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as AuthUtils from "../../src/utils/auth";
import * as Auth from "../../src/middlewares/auth";
import { DecodedIdToken } from "firebase-admin/auth";
import { NextFunction, Request, Response } from "express";
import { getCachedConfiguration } from "../../src/init/configuration";
import * as ApeKeys from "../../src/dal/ape-keys";
import { ObjectId } from "mongodb";
import { hashSync } from "bcrypt";
import MonkeyError from "../../src/utils/error";
import * as Misc from "../../src/utils/misc";
import crypto from "crypto";
import {
  EndpointMetadata,
  RequestAuthenticationOptions,
} from "@monkeytype/contracts/util/api";
import * as Prometheus from "../../src/utils/prometheus";
import { TsRestRequestWithContext } from "../../src/api/types";
import { enableMonkeyErrorExpects } from "../__testData__/monkey-error";

enableMonkeyErrorExpects();
const mockDecodedToken: DecodedIdToken = {
  uid: "123456789",
  email: "newuser@mail.com",
  iat: 0,
} as DecodedIdToken;

vi.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken);

const mockApeKey = {
  _id: new ObjectId(),
  uid: "123",
  name: "test",
  hash: hashSync("key", 5),
  createdOn: Date.now(),
  modifiedOn: Date.now(),
  lastUsedOn: Date.now(),
  useCount: 0,
  enabled: true,
};
vi.spyOn(ApeKeys, "getApeKey").mockResolvedValue(mockApeKey);
vi.spyOn(ApeKeys, "updateLastUsedOn").mockResolvedValue();
const isDevModeMock = vi.spyOn(Misc, "isDevEnvironment");
let mockRequest: Partial<TsRestRequestWithContext>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction;

describe("middlewares/auth", () => {
  beforeEach(async () => {
    isDevModeMock.mockReturnValue(true);
    let config = await getCachedConfiguration(true);
    config.apeKeys.acceptKeys = true;

    mockRequest = {
      baseUrl: "/api/v1",
      route: {
        path: "/",
      },
      headers: {
        authorization: "Bearer 123456789",
      },
      ctx: {
        configuration: config,
        decodedToken: {
          type: "None",
          uid: "",
          email: "",
        },
      },
    };
    mockResponse = {
      json: vi.fn(),
    };
    nextFunction = vi.fn((error) => {
      if (error) {
        throw error;
      }
      return "Next function called";
    }) as unknown as NextFunction;
  });

  afterEach(() => {
    isDevModeMock.mockClear();
  });

  describe("authenticateTsRestRequest", () => {
    const prometheusRecordAuthTimeMock = vi.spyOn(Prometheus, "recordAuthTime");
    const prometheusIncrementAuthMock = vi.spyOn(Prometheus, "incrementAuth");
    const timingSafeEqualMock = vi.spyOn(crypto, "timingSafeEqual");

    beforeEach(() => {
      timingSafeEqualMock.mockClear().mockReturnValue(true);
      [prometheusIncrementAuthMock, prometheusRecordAuthTimeMock].forEach(
        (it) => it.mockClear(),
      );
    });

    it("should fail if token is not fresh", async () => {
      //GIVEN
      Date.now = vi.fn(() => 60001);
      const expectedError = new MonkeyError(
        401,
        "Unauthorized\nStack: This endpoint requires a fresh token",
      );

      //WHEN
      await expect(() =>
        authenticate({}, { requireFreshToken: true }),
      ).rejects.toMatchMonkeyError(expectedError);

      //THEN

      expect(nextFunction).toHaveBeenLastCalledWith(
        expect.toMatchMonkeyError(expectedError),
      );
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
    });
    it("should allow the request if token is fresh", async () => {
      //GIVEN
      Date.now = vi.fn(() => 10000);

      //WHEN
      const result = await authenticate({}, { requireFreshToken: true });

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("Bearer");
      expect(decodedToken?.email).toBe(mockDecodedToken.email);
      expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
      expect(nextFunction).toHaveBeenCalledOnce();

      expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("Bearer");
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
    });
    it("should allow the request if apeKey is supported", async () => {
      //WHEN
      const result = await authenticate(
        { headers: { authorization: "ApeKey aWQua2V5" } },
        { acceptApeKeys: true },
      );

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("ApeKey");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("123");
      expect(nextFunction).toHaveBeenCalledTimes(1);
    });
    it("should fail with apeKey if apeKey is not supported", async () => {
      //WHEN
      await expect(() =>
        authenticate(
          { headers: { authorization: "ApeKey aWQua2V5" } },
          { acceptApeKeys: false },
        ),
      ).rejects.toThrow("This endpoint does not accept ApeKeys");

      //THEN
    });
    it("should fail with apeKey if apeKeys are disabled", async () => {
      //GIVEN

      //@ts-expect-error
      mockRequest.ctx.configuration.apeKeys.acceptKeys = false;

      //WHEN
      await expect(() =>
        authenticate(
          { headers: { authorization: "ApeKey aWQua2V5" } },
          { acceptApeKeys: false },
        ),
      ).rejects.toThrow("ApeKeys are not being accepted at this time");

      //THEN
    });
    it("should allow the request with authentation on public endpoint", async () => {
      //WHEN
      const result = await authenticate({}, { isPublic: true });

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("Bearer");
      expect(decodedToken?.email).toBe(mockDecodedToken.email);
      expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
      expect(nextFunction).toHaveBeenCalledTimes(1);
    });
    it("should allow the request without authentication on public endpoint", async () => {
      //WHEN
      const result = await authenticate({ headers: {} }, { isPublic: true });

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("None");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("");
      expect(nextFunction).toHaveBeenCalledTimes(1);

      expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None");
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
    });
    it("should allow the request with apeKey on public endpoint", async () => {
      //WHEN
      const result = await authenticate(
        { headers: { authorization: "ApeKey aWQua2V5" } },
        { isPublic: true },
      );

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("ApeKey");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("123");
      expect(nextFunction).toHaveBeenCalledTimes(1);

      expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
    });
    it("should allow request with Uid on dev", async () => {
      //WHEN
      const result = await authenticate({
        headers: { authorization: "Uid 123" },
      });

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("Bearer");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("123");
      expect(nextFunction).toHaveBeenCalledTimes(1);
    });
    it("should allow request with Uid and email on dev", async () => {
      const result = await authenticate({
        headers: { authorization: "Uid 123|test@example.com" },
      });

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("Bearer");
      expect(decodedToken?.email).toBe("test@example.com");
      expect(decodedToken?.uid).toBe("123");
      expect(nextFunction).toHaveBeenCalledTimes(1);
    });
    it("should fail request with Uid on non-dev", async () => {
      //GIVEN
      isDevModeMock.mockReturnValue(false);

      //WHEN / THEN
      await expect(() =>
        authenticate({ headers: { authorization: "Uid 123" } }),
      ).rejects.toMatchMonkeyError(
        new MonkeyError(401, "Bearer type uid is not supported"),
      );
    });
    it("should fail without authentication", async () => {
      await expect(() => authenticate({ headers: {} })).rejects.toThrow(
        "Unauthorized\nStack: endpoint: /api/v1 no authorization header found",
      );

      //THEH
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
        "None",
        "failure",
        expect.anything(),
        expect.anything(),
      );
    });
    it("should fail with empty authentication", async () => {
      await expect(() =>
        authenticate({ headers: { authorization: "" } }),
      ).rejects.toThrow(
        "Unauthorized\nStack: endpoint: /api/v1 no authorization header found",
      );

      //THEH
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
        "",
        "failure",
        expect.anything(),
        expect.anything(),
      );
    });
    it("should fail with missing authentication token", async () => {
      await expect(() =>
        authenticate({ headers: { authorization: "Bearer" } }),
      ).rejects.toThrow(
        "Missing authentication token\nStack: authenticateWithAuthHeader",
      );

      //THEH
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
        "Bearer",
        "failure",
        expect.anything(),
        expect.anything(),
      );
    });
    it("should fail with unknown authentication scheme", async () => {
      await expect(() =>
        authenticate({ headers: { authorization: "unknown format" } }),
      ).rejects.toThrow(
        'Unknown authentication scheme\nStack: The authentication scheme "unknown" is not implemented',
      );

      //THEH
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
        "unknown",
        "failure",
        expect.anything(),
        expect.anything(),
      );
    });
    it("should record country if provided", async () => {
      const prometheusRecordRequestCountryMock = vi.spyOn(
        Prometheus,
        "recordRequestCountry",
      );

      await authenticate(
        { headers: { "cf-ipcountry": "gb" } },
        { isPublic: true },
      );

      //THEN
      expect(prometheusRecordRequestCountryMock).toHaveBeenCalledWith(
        "gb",
        expect.anything(),
      );
    });
    it("should allow the request with authentation on dev public endpoint", async () => {
      //WHEN
      const result = await authenticate({}, { isPublicOnDev: true });

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("Bearer");
      expect(decodedToken?.email).toBe(mockDecodedToken.email);
      expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
      expect(nextFunction).toHaveBeenCalledTimes(1);
    });
    it("should allow the request without authentication on dev public endpoint", async () => {
      //WHEN
      const result = await authenticate(
        { headers: {} },
        { isPublicOnDev: true },
      );

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("None");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("");
      expect(nextFunction).toHaveBeenCalledTimes(1);

      expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None");
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
    });
    it("should allow the request with apeKey on dev public endpoint", async () => {
      //WHEN
      const result = await authenticate(
        { headers: { authorization: "ApeKey aWQua2V5" } },
        { acceptApeKeys: true, isPublicOnDev: true },
      );

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("ApeKey");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("123");
      expect(nextFunction).toHaveBeenCalledTimes(1);

      expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
    });
    it("should allow with apeKey if apeKeys are disabled on dev public endpoint", async () => {
      //GIVEN

      //@ts-expect-error
      mockRequest.ctx.configuration.apeKeys.acceptKeys = false;

      //WHEN
      const result = await authenticate(
        { headers: { authorization: "ApeKey aWQua2V5" } },
        { acceptApeKeys: true, isPublicOnDev: true },
      );

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("ApeKey");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("123");
      expect(nextFunction).toHaveBeenCalledTimes(1);

      expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
    });
    it("should allow the request with authentation on dev public endpoint in production", async () => {
      //WHEN
      isDevModeMock.mockReturnValue(false);
      const result = await authenticate({}, { isPublicOnDev: true });

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("Bearer");
      expect(decodedToken?.email).toBe(mockDecodedToken.email);
      expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
      expect(nextFunction).toHaveBeenCalledTimes(1);
    });
    it("should fail without authentication on dev public endpoint in production", async () => {
      //WHEN
      isDevModeMock.mockReturnValue(false);

      //THEN
      await expect(() =>
        authenticate({ headers: {} }, { isPublicOnDev: true }),
      ).rejects.toThrow("Unauthorized");
    });
    it("should allow with apeKey on dev public endpoint in production", async () => {
      //WHEN
      isDevModeMock.mockReturnValue(false);
      const result = await authenticate(
        { headers: { authorization: "ApeKey aWQua2V5" } },
        { acceptApeKeys: true, isPublicOnDev: true },
      );

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("ApeKey");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("123");
      expect(nextFunction).toHaveBeenCalledTimes(1);

      expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
    });
    it("should allow githubwebhook with header", async () => {
      vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
      //WHEN
      const result = await authenticate(
        {
          headers: { "x-hub-signature-256": "the-signature" },
          body: { action: "published", release: { id: 1 } },
        },
        { isGithubWebhook: true },
      );

      //THEN
      const decodedToken = result.decodedToken;
      expect(decodedToken?.type).toBe("GithubWebhook");
      expect(decodedToken?.email).toBe("");
      expect(decodedToken?.uid).toBe("");
      expect(nextFunction).toHaveBeenCalledTimes(1);

      expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("GithubWebhook");
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
      expect(timingSafeEqualMock).toHaveBeenCalledWith(
        Buffer.from(
          "sha256=ff0f3080539e9df19153f6b5b5780f66e558d61038e6cf5ecf4efdc7266a7751",
        ),
        Buffer.from("the-signature"),
      );
    });
    it("should fail githubwebhook with mismatched signature", async () => {
      vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
      timingSafeEqualMock.mockReturnValue(false);

      await expect(() =>
        authenticate(
          {
            headers: { "x-hub-signature-256": "the-signature" },
            body: { action: "published", release: { id: 1 } },
          },
          { isGithubWebhook: true },
        ),
      ).rejects.toThrow("Github webhook signature invalid");

      //THEH
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
        "None",
        "failure",
        expect.anything(),
        expect.anything(),
      );
    });
    it("should fail without header when endpoint is using githubwebhook", async () => {
      vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
      await expect(() =>
        authenticate(
          {
            headers: {},
            body: { action: "published", release: { id: 1 } },
          },
          { isGithubWebhook: true },
        ),
      ).rejects.toThrow("Missing Github signature header");

      //THEH
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
        "None",
        "failure",
        expect.anything(),
        expect.anything(),
      );
    });
    it("should fail with missing GITHUB_WEBHOOK_SECRET when endpoint is using githubwebhook", async () => {
      vi.stubEnv("GITHUB_WEBHOOK_SECRET", "");
      await expect(() =>
        authenticate(
          {
            headers: { "x-hub-signature-256": "the-signature" },
            body: { action: "published", release: { id: 1 } },
          },
          { isGithubWebhook: true },
        ),
      ).rejects.toThrow("Missing Github Webhook Secret");

      //THEH
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
        "None",
        "failure",
        expect.anything(),
        expect.anything(),
      );
    });
    it("should throw 500 if something went wrong when validating the signature when endpoint is using githubwebhook", async () => {
      vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
      timingSafeEqualMock.mockImplementation(() => {
        throw new Error("could not validate");
      });
      await expect(() =>
        authenticate(
          {
            headers: { "x-hub-signature-256": "the-signature" },
            body: { action: "published", release: { id: 1 } },
          },
          { isGithubWebhook: true },
        ),
      ).rejects.toThrow(
        "Failed to authenticate Github webhook: could not validate",
      );

      //THEH
      expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
      expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
        "None",
        "failure",
        expect.anything(),
        expect.anything(),
      );
    });
  });
});

async function authenticate(
  request: Partial<Request>,
  authenticationOptions?: RequestAuthenticationOptions,
): Promise<{ decodedToken: Auth.DecodedToken }> {
  const mergedRequest = {
    ...mockRequest,
    ...request,
    tsRestRoute: {
      metadata: { authenticationOptions } as EndpointMetadata,
    },
  } as any;

  await Auth.authenticateTsRestRequest()(
    mergedRequest,
    mockResponse as Response,
    nextFunction,
  );

  return { decodedToken: mergedRequest.ctx.decodedToken };
}
