import { mock, MockProxy } from "jest-mock-extended";
import { ObservedValueOf, of } from "rxjs";

// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import { UserId } from "@bitwarden/user-core";

import { mockAccountInfoWith } from "../../spec";
import { AccountService } from "../auth/abstractions/account.service";
import { TokenService } from "../auth/abstractions/token.service";
import { DeviceType } from "../enums";
import {
  VaultTimeoutAction,
  VaultTimeoutSettingsService,
  VaultTimeoutStringType,
} from "../key-management/vault-timeout";
import { ErrorResponse } from "../models/response/error.response";
import { AppIdService } from "../platform/abstractions/app-id.service";
import { Environment, EnvironmentService } from "../platform/abstractions/environment.service";
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";

import { InsecureUrlNotAllowedError } from "./api-errors";
import { ApiService, HttpOperations } from "./api.service";

describe("ApiService", () => {
  let tokenService: MockProxy<TokenService>;
  let platformUtilsService: MockProxy<PlatformUtilsService>;
  let environmentService: MockProxy<EnvironmentService>;
  let appIdService: MockProxy<AppIdService>;
  let refreshAccessTokenErrorCallback: jest.Mock<void, []>;
  let logService: MockProxy<LogService>;
  let logoutCallback: jest.Mock<Promise<void>, [reason: LogoutReason]>;
  let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
  let accountService: MockProxy<AccountService>;
  let httpOperations: MockProxy<HttpOperations>;

  let sut: ApiService;

  const testActiveUser = "activeUser" as UserId;
  const testInactiveUser = "inactiveUser" as UserId;
  const kdfFields = { Kdf: 0, KdfIterations: 600_000 };

  beforeEach(() => {
    tokenService = mock();
    platformUtilsService = mock();
    platformUtilsService.getDevice.mockReturnValue(DeviceType.ChromeExtension);

    environmentService = mock();
    appIdService = mock();
    refreshAccessTokenErrorCallback = jest.fn();
    logService = mock();
    logoutCallback = jest.fn();
    vaultTimeoutSettingsService = mock();
    accountService = mock();

    accountService.activeAccount$ = of({
      id: testActiveUser,
      ...mockAccountInfoWith({
        email: "user1@example.com",
        name: "Test Name",
      }),
    } satisfies ObservedValueOf<AccountService["activeAccount$"]>);

    httpOperations = mock();

    sut = new ApiService(
      tokenService,
      platformUtilsService,
      environmentService,
      appIdService,
      refreshAccessTokenErrorCallback,
      logService,
      logoutCallback,
      vaultTimeoutSettingsService,
      accountService,
      httpOperations,
      "custom-user-agent",
    );
  });

  describe("send", () => {
    it("handles ok GET", async () => {
      environmentService.environment$ = of({
        getApiUrl: () => "https://example.com",
      } satisfies Partial<Environment> as Environment);

      environmentService.getEnvironment$.mockReturnValue(
        of({
          getApiUrl: () => "https://authed.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      tokenService.getAccessToken.mockResolvedValue("access_token");
      tokenService.tokenNeedsRefresh.mockResolvedValue(false);

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: true,
          status: 200,
          json: () => Promise.resolve({ hello: "world" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      const response = await sut.send("GET", "/something", null, true, true, null, null);

      expect(nativeFetch).toHaveBeenCalledTimes(1);
      const request = nativeFetch.mock.calls[0][0];
      expect(request.url).toBe("https://authed.example.com/something");
      // This should get set for users of send
      expect(request.cache).toBe("no-store");
      // TODO: Could expect on the credentials parameter
      expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension
      // Custom user agent should get set
      expect(request.headers.get("User-Agent")).toBe("custom-user-agent");
      // This should be set when the caller has indicated there is a response
      expect(request.headers.get("Accept")).toBe("application/json");
      // If they have indicated that it's authed, then the authorization header should get set.
      expect(request.headers.get("Authorization")).toBe("Bearer access_token");
      // The response body
      expect(response).toEqual({ hello: "world" });
    });

    it("authenticates with non-active user when user is passed in", async () => {
      environmentService.environment$ = of({
        getApiUrl: () => "https://example.com",
      } satisfies Partial<Environment> as Environment);

      environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValueOnce(
        of({
          getApiUrl: () => "https://inactive.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      tokenService.getAccessToken
        .calledWith(testInactiveUser)
        .mockResolvedValue("inactive_access_token");

      tokenService.tokenNeedsRefresh.calledWith(testInactiveUser).mockResolvedValue(false);

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: true,
          status: 200,
          json: () => Promise.resolve({ hello: "world" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      const response = await sut.send(
        "GET",
        "/something",
        null,
        testInactiveUser,
        true,
        null,
        null,
      );

      expect(nativeFetch).toHaveBeenCalledTimes(1);
      const request = nativeFetch.mock.calls[0][0];
      expect(request.url).toBe("https://inactive.example.com/something");
      // This should get set for users of send
      expect(request.cache).toBe("no-store");
      // TODO: Could expect on the credentials parameter
      expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension
      // Custom user agent should get set
      expect(request.headers.get("User-Agent")).toBe("custom-user-agent");
      // This should be set when the caller has indicated there is a response
      expect(request.headers.get("Accept")).toBe("application/json");
      // If they have indicated that it's authed, then the authorization header should get set.
      expect(request.headers.get("Authorization")).toBe("Bearer inactive_access_token");
      // The response body
      expect(response).toEqual({ hello: "world" });
    });

    const cases: {
      name: string;
      authedOrUserId: boolean | UserId;
      expectedEffectiveUser: UserId;
    }[] = [
      {
        name: "refreshes active user when true passed in for auth",
        authedOrUserId: true,
        expectedEffectiveUser: testActiveUser,
      },
      {
        name: "refreshes access token when the user passed in happens to be the active one",
        authedOrUserId: testActiveUser,
        expectedEffectiveUser: testActiveUser,
      },
      {
        name: "refreshes access token when the user passed in happens to be inactive",
        authedOrUserId: testInactiveUser,
        expectedEffectiveUser: testInactiveUser,
      },
    ];

    it.each(cases)("$name does", async ({ authedOrUserId, expectedEffectiveUser }) => {
      environmentService.getEnvironment$.calledWith(expectedEffectiveUser).mockReturnValue(
        of({
          getApiUrl: () => `https://${expectedEffectiveUser}.example.com`,
          getIdentityUrl: () => `https://${expectedEffectiveUser}.identity.example.com`,
        } satisfies Partial<Environment> as Environment),
      );

      tokenService.getAccessToken
        .calledWith(expectedEffectiveUser)
        .mockResolvedValue(`${expectedEffectiveUser}_access_token`);

      tokenService.tokenNeedsRefresh.calledWith(expectedEffectiveUser).mockResolvedValue(true);

      tokenService.getRefreshToken
        .calledWith(expectedEffectiveUser)
        .mockResolvedValue(`${expectedEffectiveUser}_refresh_token`);

      tokenService.decodeAccessToken
        .calledWith(expectedEffectiveUser)
        .mockResolvedValue({ client_id: "web" });

      tokenService.decodeAccessToken
        .calledWith(`${expectedEffectiveUser}_new_access_token`)
        .mockResolvedValue({ sub: expectedEffectiveUser });

      vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
        .calledWith(expectedEffectiveUser)
        .mockReturnValue(of(VaultTimeoutAction.Lock));

      vaultTimeoutSettingsService.getVaultTimeoutByUserId$
        .calledWith(expectedEffectiveUser)
        .mockReturnValue(of(VaultTimeoutStringType.Never));

      tokenService.setTokens
        .calledWith(
          `${expectedEffectiveUser}_new_access_token`,
          VaultTimeoutAction.Lock,
          VaultTimeoutStringType.Never,
          `${expectedEffectiveUser}_new_refresh_token`,
        )
        .mockResolvedValue({ accessToken: `${expectedEffectiveUser}_refreshed_access_token` });

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        if (request.url.includes("identity")) {
          return Promise.resolve({
            ok: true,
            status: 200,
            json: () =>
              Promise.resolve({
                access_token: `${expectedEffectiveUser}_new_access_token`,
                token_type: "Bearer",
                refresh_token: `${expectedEffectiveUser}_new_refresh_token`,
                ...kdfFields,
              }),
          } satisfies Partial<Response> as Response);
        }
        return Promise.resolve({
          ok: true,
          status: 200,
          json: () => Promise.resolve({ hello: "world" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      await sut.send("GET", "/something", null, authedOrUserId, true, null, null);

      expect(nativeFetch).toHaveBeenCalledTimes(2);
    });
  });

  const errorData: {
    name: string;
    input: Partial<Response>;
    error: Partial<ErrorResponse>;
  }[] = [
    {
      name: "json response in camel case",
      input: {
        json: () => Promise.resolve({ message: "Something bad happened." }),
        headers: new Headers({
          "content-type": "application/json",
        }),
      },
      error: {
        message: "Something bad happened.",
      },
    },
    {
      name: "json response in pascal case",
      input: {
        json: () => Promise.resolve({ Message: "Something bad happened." }),
        headers: new Headers({
          "content-type": "application/json",
        }),
      },
      error: {
        message: "Something bad happened.",
      },
    },
    {
      name: "json response with charset in content type",
      input: {
        json: () => Promise.resolve({ message: "Something bad happened." }),
        headers: new Headers({
          "content-type": "application/json; charset=utf-8",
        }),
      },
      error: {
        message: "Something bad happened.",
      },
    },
    {
      name: "text/plain response",
      input: {
        text: () => Promise.resolve("Something bad happened."),
        headers: new Headers({
          "content-type": "text/plain",
        }),
      },
      error: {
        message: "Something bad happened.",
      },
    },
  ];

  it.each(errorData)(
    "throws error-like response when not ok response with $name",
    async ({ input, error }) => {
      environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: false,
          status: 400,
          ...input,
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      await expect(
        async () => await sut.send("GET", "/something", null, true, true, null, null),
      ).rejects.toMatchObject(error);
    },
  );

  it("throws error when trying to fetch an insecure URL", async () => {
    environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
      of({
        getApiUrl: () => "http://example.com",
      } satisfies Partial<Environment> as Environment),
    );

    httpOperations.createRequest.mockImplementation((url, request) => {
      return {
        url: url,
        cache: request.cache,
        credentials: request.credentials,
        method: request.method,
        mode: request.mode,
        signal: request.signal ?? undefined,
        headers: new Headers(request.headers),
      } satisfies Partial<Request> as unknown as Request;
    });

    const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
    nativeFetch.mockImplementation((request) => {
      return Promise.resolve({
        ok: true,
        status: 204,
        headers: new Headers(),
      } satisfies Partial<Response> as Response);
    });
    sut.nativeFetch = nativeFetch;

    await expect(
      async () => await sut.send("GET", "/something", null, true, true, null),
    ).rejects.toThrow(InsecureUrlNotAllowedError);
    expect(nativeFetch).not.toHaveBeenCalled();
  });

  describe("When a 401 Unauthorized status is received", () => {
    it("retries request with refreshed token when initial request with access token returns 401", async () => {
      // This test verifies the 401 retry flow:
      // 1. Initial request with valid token returns 401 (token expired server-side)
      // 2. After 401, buildRequest is called again, which checks tokenNeedsRefresh
      // 3. tokenNeedsRefresh returns true, triggering refreshToken via getActiveBearerToken
      // 4. refreshToken makes an HTTP call to /connect/token to get new tokens
      // 5. setTokens is called to store the new tokens, returning the refreshed access token
      // 6. Request is retried with the refreshed token and succeeds
      environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://example.com",
          getIdentityUrl: () => "https://identity.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });
      tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
      // First call (initial request): token doesn't need refresh yet
      // Subsequent calls (after 401): token needs refresh, triggering the refresh flow
      tokenService.tokenNeedsRefresh
        .calledWith(testActiveUser)
        .mockResolvedValueOnce(false)
        .mockResolvedValue(true);

      tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");

      tokenService.decodeAccessToken
        .calledWith(testActiveUser)
        .mockResolvedValue({ client_id: "web" });

      tokenService.decodeAccessToken
        .calledWith("new_access_token")
        .mockResolvedValue({ sub: testActiveUser });

      vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
        .calledWith(testActiveUser)
        .mockReturnValue(of(VaultTimeoutAction.Lock));

      vaultTimeoutSettingsService.getVaultTimeoutByUserId$
        .calledWith(testActiveUser)
        .mockReturnValue(of(VaultTimeoutStringType.Never));

      tokenService.setTokens
        .calledWith(
          "new_access_token",
          VaultTimeoutAction.Lock,
          VaultTimeoutStringType.Never,
          "new_refresh_token",
        )
        .mockResolvedValue({ accessToken: "new_access_token" });

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
      let callCount = 0;

      nativeFetch.mockImplementation((request) => {
        callCount++;

        // First call: initial request with expired token returns 401
        if (callCount === 1) {
          return Promise.resolve({
            ok: false,
            status: 401,
            json: () => Promise.resolve({ message: "Unauthorized" }),
            headers: new Headers({
              "content-type": "application/json",
            }),
          } satisfies Partial<Response> as Response);
        }

        // Second call: token refresh request
        if (callCount === 2 && request.url.includes("identity")) {
          return Promise.resolve({
            ok: true,
            status: 200,
            json: () =>
              Promise.resolve({
                access_token: "new_access_token",
                token_type: "Bearer",
                refresh_token: "new_refresh_token",
                ...kdfFields,
              }),
          } satisfies Partial<Response> as Response);
        }

        // Third call: retry with refreshed token succeeds
        if (callCount === 3) {
          expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
          return Promise.resolve({
            ok: true,
            status: 200,
            json: () => Promise.resolve({ data: "success" }),
            headers: new Headers({
              "content-type": "application/json",
            }),
          } satisfies Partial<Response> as Response);
        }

        throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
      });

      sut.nativeFetch = nativeFetch;

      const response = await sut.send("GET", "/something", null, true, true, null, null);

      expect(nativeFetch).toHaveBeenCalledTimes(3);
      expect(response).toEqual({ data: "success" });
    });

    it("does not retry when request has no access token and returns 401", async () => {
      environmentService.environment$ = of({
        getApiUrl: () => "https://example.com",
      } satisfies Partial<Environment> as Environment);

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: false,
          status: 401,
          json: () => Promise.resolve({ message: "Unauthorized" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      await expect(
        async () => await sut.send("GET", "/something", null, false, true, null, null),
      ).rejects.toMatchObject({ message: "Unauthorized" });

      // Should only be called once (no retry)
      expect(nativeFetch).toHaveBeenCalledTimes(1);
    });

    it("does not retry when request returns non-401 error", async () => {
      environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://example.com",
          getIdentityUrl: () => "https://identity.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
      tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: false,
          status: 400,
          json: () => Promise.resolve({ message: "Bad Request" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      await expect(
        async () => await sut.send("GET", "/something", null, true, true, null, null),
      ).rejects.toMatchObject({ message: "Bad Request" });

      // Should only be called once (no retry for non-401 errors)
      expect(nativeFetch).toHaveBeenCalledTimes(1);
    });

    it("does not attempt to log out unauthenticated user", async () => {
      environmentService.environment$ = of({
        getApiUrl: () => "https://example.com",
      } satisfies Partial<Environment> as Environment);

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: false,
          status: 401,
          json: () => Promise.resolve({ message: "Unauthorized" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      await expect(
        async () => await sut.send("GET", "/something", null, false, true, null, null),
      ).rejects.toMatchObject({ message: "Unauthorized" });

      expect(logoutCallback).not.toHaveBeenCalled();
    });

    it("does not retry when hasResponse is false", async () => {
      environmentService.environment$ = of({
        getApiUrl: () => "https://example.com",
      } satisfies Partial<Environment> as Environment);

      environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://example.com",
          getIdentityUrl: () => "https://identity.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
      tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: false,
          status: 401,
          json: () => Promise.resolve({ message: "Unauthorized" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      // When hasResponse is false, the method should throw even though no retry happens
      await expect(
        async () => await sut.send("POST", "/something", null, true, false, null, null),
      ).rejects.toMatchObject({ message: "Unauthorized" });

      // Should only be called once (no retry when hasResponse is false)
      expect(nativeFetch).toHaveBeenCalledTimes(1);
    });

    it("uses original user token for retry even if active user changes between requests", async () => {
      // Setup: Initial request is for testActiveUser, but during the retry, the active user switches
      // to testInactiveUser. The retry should still use testActiveUser's refreshed token.

      let activeUserId = testActiveUser;

      // Mock accountService to return different active users based on when it's called
      accountService.activeAccount$ = of({
        id: activeUserId,
        ...mockAccountInfoWith({
          email: "user1@example.com",
          name: "Test Name",
        }),
      } satisfies ObservedValueOf<AccountService["activeAccount$"]>);

      environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://example.com",
          getIdentityUrl: () => "https://identity.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://inactive.example.com",
          getIdentityUrl: () => "https://identity.inactive.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      tokenService.getAccessToken
        .calledWith(testActiveUser)
        .mockResolvedValue("active_access_token");
      tokenService.tokenNeedsRefresh
        .calledWith(testActiveUser)
        .mockResolvedValueOnce(false)
        .mockResolvedValue(true);

      tokenService.getRefreshToken
        .calledWith(testActiveUser)
        .mockResolvedValue("active_refresh_token");

      tokenService.decodeAccessToken
        .calledWith(testActiveUser)
        .mockResolvedValue({ client_id: "web" });

      tokenService.decodeAccessToken
        .calledWith("active_new_access_token")
        .mockResolvedValue({ sub: testActiveUser });

      vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
        .calledWith(testActiveUser)
        .mockReturnValue(of(VaultTimeoutAction.Lock));

      vaultTimeoutSettingsService.getVaultTimeoutByUserId$
        .calledWith(testActiveUser)
        .mockReturnValue(of(VaultTimeoutStringType.Never));

      tokenService.setTokens
        .calledWith(
          "active_new_access_token",
          VaultTimeoutAction.Lock,
          VaultTimeoutStringType.Never,
          "active_new_refresh_token",
        )
        .mockResolvedValue({ accessToken: "active_new_access_token" });

      // Mock tokens for inactive user (should NOT be used)
      tokenService.getAccessToken
        .calledWith(testInactiveUser)
        .mockResolvedValue("inactive_access_token");

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
      let callCount = 0;

      nativeFetch.mockImplementation((request) => {
        callCount++;

        // First call: initial request with active user's token returns 401
        if (callCount === 1) {
          expect(request.url).toBe("https://example.com/something");
          expect(request.headers.get("Authorization")).toBe("Bearer active_access_token");

          // After the 401, simulate active user changing
          activeUserId = testInactiveUser;
          accountService.activeAccount$ = of({
            id: testInactiveUser,
            ...mockAccountInfoWith({
              email: "user2@example.com",
              name: "Inactive User",
            }),
          } satisfies ObservedValueOf<AccountService["activeAccount$"]>);

          return Promise.resolve({
            ok: false,
            status: 401,
            json: () => Promise.resolve({ message: "Unauthorized" }),
            headers: new Headers({
              "content-type": "application/json",
            }),
          } satisfies Partial<Response> as Response);
        }

        // Second call: token refresh request for ORIGINAL user (testActiveUser)
        if (callCount === 2 && request.url.includes("identity")) {
          expect(request.url).toContain("identity.example.com");
          return Promise.resolve({
            ok: true,
            status: 200,
            json: () =>
              Promise.resolve({
                access_token: "active_new_access_token",
                token_type: "Bearer",
                refresh_token: "active_new_refresh_token",
                ...kdfFields,
              }),
          } satisfies Partial<Response> as Response);
        }

        // Third call: retry with ORIGINAL user's refreshed token, NOT the new active user's token
        if (callCount === 3) {
          expect(request.url).toBe("https://example.com/something");
          expect(request.headers.get("Authorization")).toBe("Bearer active_new_access_token");
          // Verify we're NOT using the inactive user's endpoint
          expect(request.url).not.toContain("inactive");
          return Promise.resolve({
            ok: true,
            status: 200,
            json: () => Promise.resolve({ data: "success with original user" }),
            headers: new Headers({
              "content-type": "application/json",
            }),
          } satisfies Partial<Response> as Response);
        }

        throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
      });

      sut.nativeFetch = nativeFetch;

      // Explicitly pass testActiveUser to ensure the request is for that specific user
      const response = await sut.send("GET", "/something", null, testActiveUser, true, null, null);

      expect(nativeFetch).toHaveBeenCalledTimes(3);
      expect(response).toEqual({ data: "success with original user" });

      // Verify that inactive user's token was never requested
      expect(tokenService.getAccessToken.calledWith(testInactiveUser)).not.toHaveBeenCalled();
    });

    it("throws error when retry also returns 401", async () => {
      environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://example.com",
          getIdentityUrl: () => "https://identity.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
      // First call (initial request): token doesn't need refresh yet
      // Subsequent calls (after 401): token needs refresh, triggering the refresh flow
      tokenService.tokenNeedsRefresh
        .calledWith(testActiveUser)
        .mockResolvedValueOnce(false)
        .mockResolvedValue(true);

      tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");

      tokenService.decodeAccessToken
        .calledWith(testActiveUser)
        .mockResolvedValue({ client_id: "web" });

      tokenService.decodeAccessToken
        .calledWith("new_access_token")
        .mockResolvedValue({ sub: testActiveUser });

      vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
        .calledWith(testActiveUser)
        .mockReturnValue(of(VaultTimeoutAction.Lock));

      vaultTimeoutSettingsService.getVaultTimeoutByUserId$
        .calledWith(testActiveUser)
        .mockReturnValue(of(VaultTimeoutStringType.Never));

      tokenService.setTokens
        .calledWith(
          "new_access_token",
          VaultTimeoutAction.Lock,
          VaultTimeoutStringType.Never,
          "new_refresh_token",
        )
        .mockResolvedValue({ accessToken: "new_access_token" });

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
      let callCount = 0;

      nativeFetch.mockImplementation((request) => {
        callCount++;

        // First call: initial request with expired token returns 401
        if (callCount === 1) {
          return Promise.resolve({
            ok: false,
            status: 401,
            json: () => Promise.resolve({ message: "Unauthorized" }),
            headers: new Headers({
              "content-type": "application/json",
            }),
          } satisfies Partial<Response> as Response);
        }

        // Second call: token refresh request
        if (callCount === 2 && request.url.includes("identity")) {
          return Promise.resolve({
            ok: true,
            status: 200,
            json: () =>
              Promise.resolve({
                access_token: "new_access_token",
                token_type: "Bearer",
                refresh_token: "new_refresh_token",
                ...kdfFields,
              }),
          } satisfies Partial<Response> as Response);
        }

        // Third call: retry with refreshed token still returns 401 (user no longer has permission)
        if (callCount === 3) {
          return Promise.resolve({
            ok: false,
            status: 401,
            json: () => Promise.resolve({ message: "Still Unauthorized" }),
            headers: new Headers({
              "content-type": "application/json",
            }),
          } satisfies Partial<Response> as Response);
        }

        throw new Error("Unexpected call");
      });

      sut.nativeFetch = nativeFetch;

      await expect(
        async () => await sut.send("GET", "/something", null, true, true, null, null),
      ).rejects.toMatchObject({ message: "Still Unauthorized" });

      expect(nativeFetch).toHaveBeenCalledTimes(3);
      expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
    });

    it("handles concurrent requests that both receive 401 and share token refresh", async () => {
      // This test verifies the race condition scenario:
      // 1. Request A starts with valid token
      // 2. Request B starts with valid token
      // 3. Request A gets 401, triggers refresh
      // 4. Request B gets 401 while A is refreshing
      // 5. Request B should wait for A's refresh to complete (via refreshTokenPromise cache)
      // 6. Both requests retry with the new token

      environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://example.com",
          getIdentityUrl: () => "https://identity.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");

      // First two calls: token doesn't need refresh yet
      // Subsequent calls: token needs refresh
      tokenService.tokenNeedsRefresh
        .calledWith(testActiveUser)
        .mockResolvedValueOnce(false) // Request A initial
        .mockResolvedValueOnce(false) // Request B initial
        .mockResolvedValue(true); // Both retries after 401

      tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");

      tokenService.decodeAccessToken
        .calledWith(testActiveUser)
        .mockResolvedValue({ client_id: "web" });

      tokenService.decodeAccessToken
        .calledWith("new_access_token")
        .mockResolvedValue({ sub: testActiveUser });

      vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
        .calledWith(testActiveUser)
        .mockReturnValue(of(VaultTimeoutAction.Lock));

      vaultTimeoutSettingsService.getVaultTimeoutByUserId$
        .calledWith(testActiveUser)
        .mockReturnValue(of(VaultTimeoutStringType.Never));

      tokenService.setTokens
        .calledWith(
          "new_access_token",
          VaultTimeoutAction.Lock,
          VaultTimeoutStringType.Never,
          "new_refresh_token",
        )
        .mockResolvedValue({ accessToken: "new_access_token" });

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
      let apiRequestCount = 0;
      let refreshRequestCount = 0;

      nativeFetch.mockImplementation((request) => {
        if (request.url.includes("identity")) {
          refreshRequestCount++;
          // Simulate slow token refresh to expose race condition
          return new Promise((resolve) =>
            setTimeout(
              () =>
                resolve({
                  ok: true,
                  status: 200,
                  json: () =>
                    Promise.resolve({
                      access_token: "new_access_token",
                      token_type: "Bearer",
                      refresh_token: "new_refresh_token",
                      Kdf: 0,
                      KdfIterations: 600_000,
                    }),
                } satisfies Partial<Response> as Response),
              100,
            ),
          );
        }

        apiRequestCount++;
        const currentCall = apiRequestCount;

        // First two calls (Request A and B initial attempts): both return 401
        if (currentCall === 1 || currentCall === 2) {
          return Promise.resolve({
            ok: false,
            status: 401,
            json: () => Promise.resolve({ message: "Unauthorized" }),
            headers: new Headers({
              "content-type": "application/json",
            }),
          } satisfies Partial<Response> as Response);
        }

        // Third and fourth calls (retries after refresh): both succeed
        if (currentCall === 3 || currentCall === 4) {
          expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
          return Promise.resolve({
            ok: true,
            status: 200,
            json: () => Promise.resolve({ data: `success-${currentCall}` }),
            headers: new Headers({
              "content-type": "application/json",
            }),
          } satisfies Partial<Response> as Response);
        }

        throw new Error(`Unexpected API call #${currentCall}: ${request.method} ${request.url}`);
      });

      sut.nativeFetch = nativeFetch;

      // Make two concurrent requests
      const [responseA, responseB] = await Promise.all([
        sut.send("GET", "/endpoint-a", null, testActiveUser, true, null, null),
        sut.send("GET", "/endpoint-b", null, testActiveUser, true, null, null),
      ]);

      // Both requests should succeed
      expect(responseA).toMatchObject({ data: expect.stringContaining("success") });
      expect(responseB).toMatchObject({ data: expect.stringContaining("success") });

      // Verify only ONE token refresh was made (they shared the refresh)
      expect(refreshRequestCount).toBe(1);

      // Verify the total number of API requests: 2 initial + 2 retries = 4
      expect(apiRequestCount).toBe(4);

      // Verify setTokens was only called once
      expect(tokenService.setTokens).toHaveBeenCalledTimes(1);
    });
  });

  describe("When 403 Forbidden response is received from API request", () => {
    it("logs out the authenticated user", async () => {
      environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
        of({
          getApiUrl: () => "https://example.com",
        } satisfies Partial<Environment> as Environment),
      );

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
      tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: false,
          status: 403,
          json: () => Promise.resolve({ message: "Forbidden" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      await expect(
        async () => await sut.send("GET", "/something", null, true, true, null, null),
      ).rejects.toMatchObject({ message: "Forbidden" });

      expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
    });

    it("does not attempt to log out unauthenticated user", async () => {
      environmentService.environment$ = of({
        getApiUrl: () => "https://example.com",
      } satisfies Partial<Environment> as Environment);

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();

      nativeFetch.mockImplementation((request) => {
        return Promise.resolve({
          ok: false,
          status: 403,
          json: () => Promise.resolve({ message: "Forbidden" }),
          headers: new Headers({
            "content-type": "application/json",
          }),
        } satisfies Partial<Response> as Response);
      });

      sut.nativeFetch = nativeFetch;

      await expect(
        async () => await sut.send("GET", "/something", null, false, true, null, null),
      ).rejects.toMatchObject({ message: "Forbidden" });

      expect(logoutCallback).not.toHaveBeenCalled();
    });
  });

  describe("fetch", () => {
    it("does not execute any middlewares when none are registered", async () => {
      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
      nativeFetch.mockResolvedValue({
        ok: true,
        status: 200,
      } satisfies Partial<Response> as Response);
      sut.nativeFetch = nativeFetch;

      const request = {
        url: "https://example.com/api",
        method: "POST",
        headers: { set: jest.fn() },
      } as unknown as Request;
      await sut.fetch(request);

      expect(nativeFetch).toHaveBeenCalledTimes(1);
    });

    it("executes a registered middleware before sending the request", async () => {
      const middleware = jest.fn<Promise<Response>, [Request, (req: Request) => Promise<Response>]>(
        async (req, next) => next(req),
      );
      sut.addMiddleware(middleware);

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
      nativeFetch.mockResolvedValue({
        ok: true,
        status: 200,
      } satisfies Partial<Response> as Response);
      sut.nativeFetch = nativeFetch;

      const request = {
        url: "https://example.com/api",
        method: "POST",
        headers: { set: jest.fn() },
      } as unknown as Request;
      await sut.fetch(request);

      expect(middleware).toHaveBeenCalledTimes(1);
      expect(middleware).toHaveBeenCalledWith(request, expect.any(Function));
      expect(nativeFetch).toHaveBeenCalledTimes(1);
    });

    it("executes all registered middlewares before sending the request", async () => {
      const callOrder: number[] = [];
      const middleware1 = jest
        .fn<Promise<Response>, [Request, (req: Request) => Promise<Response>]>()
        .mockImplementation(async (req, next) => {
          callOrder.push(1);
          return next(req);
        });
      const middleware2 = jest
        .fn<Promise<Response>, [Request, (req: Request) => Promise<Response>]>()
        .mockImplementation(async (req, next) => {
          callOrder.push(2);
          return next(req);
        });
      sut.addMiddleware(middleware1);
      sut.addMiddleware(middleware2);

      const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
      nativeFetch.mockResolvedValue({
        ok: true,
        status: 200,
      } satisfies Partial<Response> as Response);
      sut.nativeFetch = nativeFetch;

      const request = {
        url: "https://example.com/api",
        method: "POST",
        headers: { set: jest.fn() },
      } as unknown as Request;
      await sut.fetch(request);

      expect(middleware1).toHaveBeenCalledTimes(1);
      expect(middleware2).toHaveBeenCalledTimes(1);
      expect(nativeFetch).toHaveBeenCalledTimes(1);
    });
  });

  describe("refreshAccessToken", () => {
    const userId = testActiveUser;
    const refreshTokenValue = "refresh_token_value";
    const newAccessToken = "new_access_token";
    const newRefreshToken = "new_refresh_token";
    const refreshedAccessToken = "refreshed_access_token";

    beforeEach(() => {
      environmentService.getEnvironment$.calledWith(userId).mockReturnValue(
        of({
          getIdentityUrl: () => "https://identity.example.com",
        } satisfies Partial<Environment> as Environment),
      );

      tokenService.getRefreshToken.calledWith(userId).mockResolvedValue(refreshTokenValue);

      tokenService.decodeAccessToken
        .calledWith(userId)
        .mockResolvedValue({ client_id: "web" } as any);

      tokenService.decodeAccessToken
        .calledWith(newAccessToken)
        .mockResolvedValue({ sub: userId } as any);

      vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
        .calledWith(userId)
        .mockReturnValue(of(VaultTimeoutAction.Lock));

      vaultTimeoutSettingsService.getVaultTimeoutByUserId$
        .calledWith(userId)
        .mockReturnValue(of(VaultTimeoutStringType.Never));

      tokenService.setTokens.mockResolvedValue({ accessToken: refreshedAccessToken } as any);

      httpOperations.createRequest.mockImplementation((url, request) => {
        return {
          url: url,
          cache: request.cache,
          credentials: request.credentials,
          method: request.method,
          mode: request.mode,
          signal: request.signal,
          headers: new Headers(request.headers),
        } satisfies Partial<Request> as unknown as Request;
      });
    });

    it("returns new access token on success", async () => {
      sut.nativeFetch = jest.fn().mockResolvedValue({
        status: 200,
        json: () =>
          Promise.resolve({
            access_token: newAccessToken,
            token_type: "Bearer",
            refresh_token: newRefreshToken,
          }),
      } satisfies Partial<Response> as Response);

      const result = await (sut as any).refreshAccessToken(userId);

      expect(result).toEqual(refreshedAccessToken);
      expect(tokenService.setTokens).toHaveBeenCalledWith(
        newAccessToken,
        VaultTimeoutAction.Lock,
        VaultTimeoutStringType.Never,
        newRefreshToken,
      );
    });

    it("does not crash when server includes KDF fields (PM-35246 regression)", async () => {
      sut.nativeFetch = jest.fn().mockResolvedValue({
        status: 200,
        json: () =>
          Promise.resolve({
            access_token: newAccessToken,
            token_type: "Bearer",
            refresh_token: newRefreshToken,
            ...kdfFields,
          }),
      } satisfies Partial<Response> as Response);

      await expect((sut as any).refreshAccessToken(userId)).resolves.toEqual(refreshedAccessToken);
    });

    it("handles missing refresh_token in response", async () => {
      sut.nativeFetch = jest.fn().mockResolvedValue({
        status: 200,
        json: () =>
          Promise.resolve({
            access_token: newAccessToken,
            token_type: "Bearer",
          }),
      } satisfies Partial<Response> as Response);

      await (sut as any).refreshAccessToken(userId);

      expect(tokenService.setTokens).toHaveBeenCalledWith(
        newAccessToken,
        VaultTimeoutAction.Lock,
        VaultTimeoutStringType.Never,
        undefined,
      );
    });

    it("rejects on non-200 response", async () => {
      sut.nativeFetch = jest.fn().mockResolvedValue({
        status: 400,
        headers: new Headers(),
      } satisfies Partial<Response> as Response);

      await expect((sut as any).refreshAccessToken(userId)).rejects.toBeInstanceOf(ErrorResponse);
    });
  });
});
