import {
  BehaviorSubject,
  catchError,
  distinctUntilChanged,
  EMPTY,
  filter,
  firstValueFrom,
  map,
  mergeMap,
  Observable,
  share,
  switchMap,
  tap,
} 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 { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";

import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { AuthRequestAnsweringService } from "../../../auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service";
import { NotificationType, PushNotificationLogOutReasonType } from "../../../enums";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import {
  LogOutNotification,
  NotificationResponse,
  PremiumStatusChangedNotification,
  SyncCipherNotification,
  SyncFolderNotification,
  SyncSendNotification,
} from "../../../models/response/notification.response";
import { UserId } from "../../../types/guid";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { AppIdService } from "../../abstractions/app-id.service";
import { ConfigService } from "../../abstractions/config/config.service";
import { EnvironmentService } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { MessagingService } from "../../abstractions/messaging.service";
import { trackedMerge } from "../../misc";
import { supportSwitch } from "../../misc/support-status";
import { ServerNotificationsService } from "../server-notifications.service";

import { ReceiveMessage, SignalRConnectionService } from "./signalr-connection.service";
import { WebPushConnectionService } from "./webpush-connection.service";

export const DISABLED_NOTIFICATIONS_URL = "http://-";

export const AllowedMultiUserNotificationTypes = new Set<NotificationType>([
  NotificationType.AuthRequest,
  NotificationType.AutoConfirmMember,
]);

export class DefaultServerNotificationsService implements ServerNotificationsService {
  notifications$: Observable<readonly [NotificationResponse, UserId]>;

  private activitySubject = new BehaviorSubject<"active" | "inactive">("active");

  constructor(
    private readonly logService: LogService,
    private syncService: SyncService,
    private appIdService: AppIdService,
    private environmentService: EnvironmentService,
    private logoutCallback: (logoutReason: LogoutReason, userId: UserId) => Promise<void>,
    private messagingService: MessagingService,
    private readonly accountService: AccountService,
    private readonly signalRConnectionService: SignalRConnectionService,
    private readonly authService: AuthService,
    private readonly webPushConnectionService: WebPushConnectionService,
    private readonly authRequestAnsweringService: AuthRequestAnsweringService,
    private readonly configService: ConfigService,
    private autoConfirmService: AutomaticUserConfirmationService,
    private readonly billingAccountProfileStateService: BillingAccountProfileStateService,
  ) {
    this.notifications$ = this.accountService.accounts$.pipe(
      map((accounts: Record<UserId, AccountInfo>): Set<UserId> => {
        const validUserIds = Object.entries(accounts)
          .filter(([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified)
          .map(([userId, _]) => userId as UserId);
        return new Set(validUserIds);
      }),
      trackedMerge((id: UserId) => {
        return this.userNotifications$(id as UserId).pipe(
          map((notification: NotificationResponse) => [notification, id as UserId] as const),
        );
      }),
      share(), // Multiple subscribers should only create a single connection to the server
    );
  }

  /**
   * Retrieves a stream of push server notifications for the given user.
   * @param userId The user id of the user to get the push server notifications for.
   */
  private userNotifications$(userId: UserId) {
    return this.environmentService.getEnvironment$(userId).pipe(
      map((env) => env.getNotificationsUrl()),
      distinctUntilChanged(),
      switchMap((notificationsUrl) => {
        if (notificationsUrl === DISABLED_NOTIFICATIONS_URL) {
          return EMPTY;
        }

        return this.userNotificationsHelper$(userId, notificationsUrl);
      }),
    );
  }

  private userNotificationsHelper$(userId: UserId, notificationsUrl: string) {
    return this.hasAccessToken$(userId).pipe(
      switchMap((hasAccessToken) => {
        if (!hasAccessToken) {
          return EMPTY;
        }

        return this.activitySubject;
      }),
      switchMap((activityStatus) => {
        if (activityStatus === "inactive") {
          return EMPTY;
        }

        return this.webPushConnectionService.supportStatus$(userId);
      }),
      supportSwitch({
        supported: (service) => {
          this.logService.info("Using WebPush for server notifications");
          // Catch up on anything missed before this stream was (re)established
          // (e.g. an MV3 service worker suspension dropping earlier deliveries).
          void this.syncOnConnected(userId);
          return service.notifications$.pipe(
            catchError((err: unknown) => {
              this.logService.warning("Issue with web push, falling back to SignalR", err);
              return this.connectSignalR$(userId, notificationsUrl);
            }),
          );
        },
        notSupported: () => {
          this.logService.info("Using SignalR for server notifications");
          return this.connectSignalR$(userId, notificationsUrl);
        },
      }),
    );
  }

  private connectSignalR$(userId: UserId, notificationsUrl: string) {
    return this.signalRConnectionService.connect$(userId, notificationsUrl).pipe(
      tap((n) => {
        if (n.type === "Connected") {
          void this.syncOnConnected(userId);
        }
      }),
      filter((n) => n.type === "ReceiveMessage"),
      map((n) => (n as ReceiveMessage).message),
    );
  }

  private async syncOnConnected(userId: UserId) {
    try {
      const activeAccountId = await firstValueFrom(
        this.accountService.activeAccount$.pipe(map((a) => a?.id)),
      );
      if (activeAccountId !== userId) {
        return;
      }
      await this.syncService.fullSync(false);
    } catch (e) {
      this.logService.error("Failed to catch-up sync after notifications connected", e);
    }
  }

  private hasAccessToken$(userId: UserId) {
    return this.authService.authStatusFor$(userId).pipe(
      map(
        (authStatus) =>
          authStatus === AuthenticationStatus.Locked ||
          authStatus === AuthenticationStatus.Unlocked,
      ),
      distinctUntilChanged(),
    );
  }

  private async processNotification(notification: NotificationResponse, userId: UserId) {
    const appId = await this.appIdService.getAppId();
    if (notification == null || notification.contextId === appId) {
      return;
    }

    const payloadUserId = notification.payload?.userId || notification.payload?.UserId;
    if (payloadUserId != null && payloadUserId !== userId) {
      return;
    }

    const activeAccountId = await firstValueFrom(
      this.accountService.activeAccount$.pipe(map((a) => a?.id)),
    );

    const notificationIsForActiveUser = activeAccountId === userId;
    if (!notificationIsForActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) {
      return;
    }

    switch (notification.type) {
      case NotificationType.SyncCipherCreate:
      case NotificationType.SyncCipherUpdate:
        await this.syncService.syncUpsertCipher(
          notification.payload as SyncCipherNotification,
          notification.type === NotificationType.SyncCipherUpdate,
          userId,
        );
        break;
      case NotificationType.SyncCipherDelete:
      case NotificationType.SyncLoginDelete:
        await this.syncService.syncDeleteCipher(
          notification.payload as SyncCipherNotification,
          userId,
        );
        break;
      case NotificationType.SyncFolderCreate:
      case NotificationType.SyncFolderUpdate:
        await this.syncService.syncUpsertFolder(
          notification.payload as SyncFolderNotification,
          notification.type === NotificationType.SyncFolderUpdate,
          userId,
        );
        break;
      case NotificationType.SyncFolderDelete:
        await this.syncService.syncDeleteFolder(
          notification.payload as SyncFolderNotification,
          userId,
        );
        break;
      case NotificationType.SyncVault:
      case NotificationType.SyncCiphers:
      case NotificationType.SyncSettings:
        await this.syncService.fullSync(false);
        break;
      case NotificationType.SyncOrganizations:
        // An organization update may not have bumped the user's account revision date, so force a sync
        await this.syncService.fullSync(true);
        break;
      case NotificationType.SyncOrgKeys:
        await this.syncService.fullSync(true);
        this.activitySubject.next("inactive"); // Force a disconnect
        this.activitySubject.next("active"); // Allow a reconnect
        break;
      case NotificationType.LogOut: {
        this.logService.info("[Notifications Service] Received logout notification");

        const logOutNotification = notification.payload as LogOutNotification;
        const noLogoutOnKdfChange = await firstValueFrom(
          this.configService.getFeatureFlag$(FeatureFlag.NoLogoutOnKdfChange),
        );
        const noLogoutOnKeyUpgradeRotation = await firstValueFrom(
          this.configService.getFeatureFlag$(FeatureFlag.NoLogoutOnKeyUpgradeRotation),
        );
        if (
          noLogoutOnKdfChange &&
          logOutNotification.reason === PushNotificationLogOutReasonType.KdfChange
        ) {
          this.logService.info(
            "[Notifications Service] Skipping logout due to no logout KDF change",
          );
        } else if (
          noLogoutOnKeyUpgradeRotation &&
          logOutNotification.reason === PushNotificationLogOutReasonType.KeyRotation
        ) {
          this.logService.info(
            "[Notifications Service] Skipping logout due to no logout key rotation. Performing full sync.",
          );
          await this.syncService.fullSync(true);
        } else {
          await this.logoutCallback("logoutNotification", userId);
        }
        break;
      }
      case NotificationType.SyncSendCreate:
      case NotificationType.SyncSendUpdate:
        await this.syncService.syncUpsertSend(
          notification.payload as SyncSendNotification,
          notification.type === NotificationType.SyncSendUpdate,
        );
        break;
      case NotificationType.SyncSendDelete:
        await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
        break;
      case NotificationType.AuthRequest: {
        // Only Extension and Desktop implement the AuthRequestAnsweringService
        if (this.authRequestAnsweringService.receivedPendingAuthRequest) {
          try {
            await this.authRequestAnsweringService.receivedPendingAuthRequest(
              notification.payload.userId,
              notification.payload.id,
            );
          } catch (error) {
            this.logService.error(`Failed to process auth request notification: ${error}`);
          }
        } else {
          // This call is necessary for Web, which uses a NoopAuthRequestAnsweringService
          // that does not have a receivedPendingAuthRequest() method
          this.messagingService.send("openLoginApproval", {
            // Include the authRequestId so the DeviceManagementComponent can upsert the correct device.
            // This will only matter if the user is on the /device-management screen when the auth request is received.
            notificationId: notification.payload.id,
          });
        }
        break;
      }
      case NotificationType.SyncOrganizationStatusChanged:
        await this.syncService.fullSync(true);
        break;
      case NotificationType.SyncOrganizationCollectionSettingChanged:
        await this.syncService.fullSync(true);
        break;
      case NotificationType.OrganizationBankAccountVerified:
        this.messagingService.send("organizationBankAccountVerified", {
          organizationId: notification.payload.organizationId,
        });
        break;
      case NotificationType.ProviderBankAccountVerified:
        this.messagingService.send("providerBankAccountVerified", {
          providerId: notification.payload.providerId,
          adminId: notification.payload.adminId,
        });
        break;
      case NotificationType.SyncPolicy:
        // TODO (PM-38875): this should compare against local data and only fetch the specific
        // policy if required. For now just force a sync.
        await this.syncService.fullSync(true);
        break;
      case NotificationType.AutoConfirmMember:
        await this.autoConfirmService.autoConfirmUser(
          notification.payload.userId,
          notification.payload.targetUserId,
          notification.payload.targetOrganizationUserId,
          notification.payload.organizationId,
        );
        break;
      case NotificationType.PremiumStatusChanged: {
        const premiumPayload = notification.payload as PremiumStatusChangedNotification;
        const hasPremiumFromAnyOrganization = await firstValueFrom(
          this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(
            premiumPayload.userId as UserId,
          ),
        );
        await this.billingAccountProfileStateService.setHasPremium(
          premiumPayload.premium,
          hasPremiumFromAnyOrganization,
          premiumPayload.userId as UserId,
        );
        break;
      }
      default:
        break;
    }
  }

  startListening() {
    return this.notifications$
      .pipe(
        mergeMap(async ([notification, userId]) => {
          try {
            await this.processNotification(notification, userId);
          } catch (err: unknown) {
            this.logService.error(
              `Problem processing notification of type ${notification.type}`,
              err,
            );
          }
        }),
      )
      .subscribe({
        error: (err: unknown) =>
          this.logService.error(
            "Fatal error in server notifications$ observable, notifications won't be recieved anymore.",
            err,
          ),
      });
  }

  reconnectFromActivity(): void {
    this.activitySubject.next("active");
  }

  disconnectFromInactivity(): void {
    this.activitySubject.next("inactive");
  }
}
