import { signal } from '@angular/core';
import { T } from '../../t.const';
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, firstValueFrom, of } from 'rxjs';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { OperationLogSyncService } from '../../op-log/sync/operation-log-sync.service';
import { SyncSessionValidationService } from '../../op-log/sync/sync-session-validation.service';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { SnackService } from '../../core/snack/snack.service';
import { ReminderService } from '../../features/reminder/reminder.service';
import { DataInitService } from '../../core/data-init/data-init.service';
import { UserInputWaitStateService } from './user-input-wait-state.service';
import { SuperSyncStatusService } from '../../op-log/sync/super-sync-status.service';
import { SuperSyncWebSocketService } from '../../op-log/sync/super-sync-websocket.service';
import { WsTriggeredDownloadService } from '../../op-log/sync/ws-triggered-download.service';
import {
  AuthFailSPError,
  MissingCredentialsSPError,
  NetworkUnavailableSPError,
  PotentialCorsError,
  SyncProviderId,
  SyncStatus,
} from '../../op-log/sync-exports';
import {
  SyncAlreadyInProgressError,
  LocalDataConflictError,
  LockAcquisitionTimeoutError,
  MissingRefreshTokenAPIError,
  JsonParseError,
  SyncDataCorruptedError,
  UploadRevToMatchMismatchAPIError,
  WebDavNativeRequestError,
  EncryptNoPasswordError,
} from '../../op-log/core/errors/sync-errors';
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';

describe('SyncWrapperService', () => {
  let service: SyncWrapperService;
  let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
  let mockSyncService: jasmine.SpyObj<OperationLogSyncService>;
  let mockWrappedProvider: jasmine.SpyObj<WrappedProviderService>;
  let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
  let mockLegacyPfDb: jasmine.SpyObj<LegacyPfDbService>;
  let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
  let mockSnackService: jasmine.SpyObj<SnackService>;
  let mockMatDialog: jasmine.SpyObj<MatDialog>;
  let mockTranslateService: jasmine.SpyObj<TranslateService>;
  let mockDataInitService: jasmine.SpyObj<DataInitService>;
  let mockReminderService: jasmine.SpyObj<ReminderService>;
  let mockUserInputWaitState: jasmine.SpyObj<UserInputWaitStateService>;
  let mockSuperSyncStatusService: jasmine.SpyObj<SuperSyncStatusService>;
  let mockSuperSyncWsService: jasmine.SpyObj<SuperSyncWebSocketService> & {
    isConnected: ReturnType<typeof signal<boolean>>;
  };
  let mockWsDownloadService: jasmine.SpyObj<WsTriggeredDownloadService>;

  let configSubject: BehaviorSubject<any>;
  let mockSyncCapableProvider: any;

  const createMockSyncConfig = (
    provider: SyncProviderId | null,
    overrides: Record<string, unknown> = {},
  ): { sync: any } => ({
    sync: {
      syncProvider: provider,
      syncInterval: 60000,
      isManualSyncOnly: false,
      ...overrides,
    },
  });

  beforeEach(() => {
    configSubject = new BehaviorSubject(createMockSyncConfig(SyncProviderId.SuperSync));

    mockSyncCapableProvider = {
      uploadOperations: jasmine.createSpy('uploadOperations'),
      downloadOperations: jasmine.createSpy('downloadOperations'),
    };

    mockProviderManager = jasmine.createSpyObj(
      'SyncProviderManager',
      [
        'getActiveProvider',
        'setSyncStatus',
        'setProviderConfig',
        'getProviderById',
        'clearAuthCredentials',
        'getLastSyncedProviderId',
        'setLastSyncedProviderId',
      ],
      {
        syncStatus$: of('SYNCED'),
        isProviderReady$: of(true),
        isSyncInProgress: false,
      },
    );
    mockProviderManager.clearAuthCredentials.and.returnValue(Promise.resolve());
    mockProviderManager.getProviderById.and.returnValue(Promise.resolve(undefined));
    mockProviderManager.getLastSyncedProviderId.and.returnValue(null);
    mockProviderManager.getActiveProvider.and.returnValue({
      id: SyncProviderId.SuperSync,
    } as any);

    mockSyncService = jasmine.createSpyObj('OperationLogSyncService', [
      'downloadRemoteOps',
      'uploadPendingOps',
      'hasSyncedOps',
    ]);
    // Steady-state default: this client has synced before. Tests that exercise the
    // never-synced path override this.
    mockSyncService.hasSyncedOps.and.resolveTo(true);
    mockSyncService.downloadRemoteOps.and.returnValue(
      Promise.resolve({
        kind: 'no_new_ops' as const,
      }),
    );
    mockSyncService.uploadPendingOps.and.returnValue(
      Promise.resolve({
        kind: 'completed' as const,
        uploadedCount: 0,
        piggybackedOpsCount: 0,
        localWinOpsCreated: 0,
        permanentRejectionCount: 0,
        hasMorePiggyback: false,
        rejectedOps: [],
      }),
    );

    mockWrappedProvider = jasmine.createSpyObj('WrappedProviderService', [
      'getOperationSyncCapable',
    ]);
    mockWrappedProvider.getOperationSyncCapable.and.returnValue(
      Promise.resolve(mockSyncCapableProvider),
    );

    mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
      'getVectorClockEntry',
      'setVectorClock',
    ]);
    mockOpLogStore.getVectorClockEntry.and.returnValue(Promise.resolve(null));

    mockLegacyPfDb = jasmine.createSpyObj('LegacyPfDbService', [
      'loadMetaModel',
      'saveMetaModel',
    ]);
    mockLegacyPfDb.loadMetaModel.and.returnValue(Promise.resolve({}));
    mockLegacyPfDb.saveMetaModel.and.returnValue(Promise.resolve());

    mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], {
      cfg$: configSubject.asObservable(),
    });

    mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
    mockMatDialog = jasmine.createSpyObj('MatDialog', ['open'], {
      openDialogs: [],
    });
    mockTranslateService = jasmine.createSpyObj('TranslateService', ['instant']);
    mockTranslateService.instant.and.callFake((key: string) => key);

    mockDataInitService = jasmine.createSpyObj('DataInitService', [
      'reInitFromRemoteSync',
    ]);
    mockReminderService = jasmine.createSpyObj('ReminderService', ['reloadFromDatabase']);

    mockUserInputWaitState = jasmine.createSpyObj(
      'UserInputWaitStateService',
      ['startWaiting'],
      {
        isWaitingForUserInput$: of(false),
      },
    );
    // startWaiting returns a stopWaiting function
    mockUserInputWaitState.startWaiting.and.returnValue(() => {});

    mockSuperSyncStatusService = jasmine.createSpyObj(
      'SuperSyncStatusService',
      ['clearScope'],
      {
        isConfirmedInSync: signal(false),
        hasNoPendingOps: signal(false),
      },
    );

    mockSuperSyncWsService = Object.assign(
      jasmine.createSpyObj('SuperSyncWebSocketService', ['connect', 'disconnect']),
      {
        isConnected: signal(false),
      },
    );
    mockSuperSyncWsService.connect.and.returnValue(Promise.resolve());

    mockWsDownloadService = jasmine.createSpyObj('WsTriggeredDownloadService', [
      'start',
      'stop',
    ]);

    TestBed.configureTestingModule({
      providers: [
        SyncWrapperService,
        { provide: SyncProviderManager, useValue: mockProviderManager },
        { provide: OperationLogSyncService, useValue: mockSyncService },
        { provide: WrappedProviderService, useValue: mockWrappedProvider },
        { provide: OperationLogStoreService, useValue: mockOpLogStore },
        { provide: LegacyPfDbService, useValue: mockLegacyPfDb },
        { provide: GlobalConfigService, useValue: mockGlobalConfigService },
        { provide: TranslateService, useValue: mockTranslateService },
        { provide: MatDialog, useValue: mockMatDialog },
        { provide: SnackService, useValue: mockSnackService },
        { provide: DataInitService, useValue: mockDataInitService },
        { provide: ReminderService, useValue: mockReminderService },
        { provide: UserInputWaitStateService, useValue: mockUserInputWaitState },
        { provide: SuperSyncStatusService, useValue: mockSuperSyncStatusService },
        { provide: SuperSyncWebSocketService, useValue: mockSuperSyncWsService },
        { provide: WsTriggeredDownloadService, useValue: mockWsDownloadService },
      ],
    });

    service = TestBed.inject(SyncWrapperService);
  });

  describe('sync() method', () => {
    it('should return HANDLED_ERROR when sync already in progress', async () => {
      // Start first sync but don't await it
      const firstSync = service.sync();

      // Try to start another sync while first is in progress
      const secondResult = await service.sync();

      expect(secondResult).toBe('HANDLED_ERROR');

      // Clean up first sync
      await firstSync;
    });

    it('should set isSyncInProgress true during sync, false after', async () => {
      expect(service.isSyncInProgressSync()).toBe(false);

      const syncPromise = service.sync();

      // Should be true during sync
      expect(service.isSyncInProgressSync()).toBe(true);

      await syncPromise;

      // Should be false after sync
      expect(service.isSyncInProgressSync()).toBe(false);
    });

    it('should reset isSyncInProgress even on error', async () => {
      mockWrappedProvider.getOperationSyncCapable.and.returnValue(
        Promise.reject(new Error('Test error')),
      );

      expect(service.isSyncInProgressSync()).toBe(false);

      await service.sync();

      // Should be false after error
      expect(service.isSyncInProgressSync()).toBe(false);
    });

    it('should return InSync on successful sync', async () => {
      const result = await service.sync();

      expect(result).toBe(SyncStatus.InSync);
    });

    // discussion #7196: sync button must distinguish "data changed" from
    // "already up to date" instead of always showing "Already in sync".
    it('should return InSync when nothing changed (no download, nothing uploaded)', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({ kind: 'no_new_ops' as const }),
      );
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        }),
      );

      const result = await service.sync();

      expect(result).toBe(SyncStatus.InSync);
    });

    it('should return UpdateRemote when local ops were uploaded', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({ kind: 'no_new_ops' as const }),
      );
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 3,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        }),
      );

      const result = await service.sync();

      expect(result).toBe(SyncStatus.UpdateRemote);
    });

    // #8731: an encryption-mandatory provider with no key skips upload (GHSA-9v8x
    // guard) while pending ops remain unsynced. This must NOT be reported as IN_SYNC.
    it('should report UNKNOWN_OR_CHANGED (not InSync) when upload was skipped for a missing mandatory key', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({ kind: 'no_new_ops' as const }),
      );
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
          encryptionRequiredKeyMissing: true,
        }),
      );

      const result = await service.sync();

      expect(result).toBe(SyncStatus.UpdateRemote);
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
    });

    it('should return UpdateRemote when remote ops were downloaded', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'ops_processed' as const,
          newOpsCount: 5,
          localWinOpsCreated: 0,
        }),
      );
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        }),
      );

      const result = await service.sync();

      expect(result).toBe(SyncStatus.UpdateRemote);
    });
  });

  describe('syncInterval$', () => {
    it('should use 1 minute for SuperSync when websocket is disconnected', async () => {
      expect(await firstValueFrom(service.syncInterval$)).toBe(60000);
    });

    it('should use 5 minutes for SuperSync when websocket is connected', async () => {
      mockSuperSyncWsService.isConnected.set(true);
      configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));

      expect(await firstValueFrom(service.syncInterval$)).toBe(300000);
    });

    it('should return 0 for manual sync only', async () => {
      configSubject.next(
        createMockSyncConfig(SyncProviderId.SuperSync, { isManualSyncOnly: true }),
      );

      expect(await firstValueFrom(service.syncInterval$)).toBe(0);
    });

    it('should use configured interval for non-SuperSync providers', async () => {
      configSubject.next(
        createMockSyncConfig(SyncProviderId.WebDAV, { syncInterval: 120000 }),
      );

      expect(await firstValueFrom(service.syncInterval$)).toBe(120000);
    });
  });

  describe('websocket integration', () => {
    it('should disconnect websocket when provider changes away from SuperSync', async () => {
      configSubject.next(createMockSyncConfig(SyncProviderId.WebDAV));
      await Promise.resolve();

      expect(mockWsDownloadService.stop).toHaveBeenCalled();
      expect(mockSuperSyncWsService.disconnect).toHaveBeenCalled();
    });

    it('should connect websocket after successful SuperSync sync', async () => {
      const mockProvider = {
        getWebSocketParams: jasmine.createSpy().and.returnValue(
          Promise.resolve({
            baseUrl: 'https://sync.example.com',
            accessToken: 'token-123',
          }),
        ),
      };
      mockProviderManager.getProviderById.and.returnValue(
        Promise.resolve(mockProvider as any),
      );

      await service.sync();
      // Flush microtasks: connectWebSocket() is fire-and-forget (not awaited in _sync).
      // Two flushes needed: one for getProviderById, one for getWebSocketParams + connect.
      await Promise.resolve();
      await Promise.resolve();

      expect(mockProviderManager.getProviderById).toHaveBeenCalledWith(
        SyncProviderId.SuperSync,
      );
      expect(mockProvider.getWebSocketParams).toHaveBeenCalled();
      expect(mockSuperSyncWsService.connect).toHaveBeenCalledWith(
        'https://sync.example.com',
        'token-123',
      );
      expect(mockWsDownloadService.start).toHaveBeenCalled();
    });

    it('should no-op connectWebSocket when provider is not SuperSync', async () => {
      configSubject.next(createMockSyncConfig(SyncProviderId.WebDAV));
      await service.connectWebSocket();
      await Promise.resolve();

      expect(mockProviderManager.getProviderById).not.toHaveBeenCalled();
    });

    it('should no-op connectWebSocket when getWebSocketParams returns null', async () => {
      const mockProvider = {
        getWebSocketParams: jasmine.createSpy().and.returnValue(Promise.resolve(null)),
      };
      mockProviderManager.getProviderById.and.returnValue(
        Promise.resolve(mockProvider as any),
      );

      await service.connectWebSocket();
      await Promise.resolve();
      await Promise.resolve();

      expect(mockProvider.getWebSocketParams).toHaveBeenCalled();
      expect(mockSuperSyncWsService.connect).not.toHaveBeenCalled();
    });

    it('should skip WS connection after sync if already connected', async () => {
      mockSuperSyncWsService.isConnected.set(true);

      await service.sync();
      await Promise.resolve();
      await Promise.resolve();

      expect(mockSuperSyncWsService.connect).not.toHaveBeenCalled();
    });
  });

  describe('_sync() - Provider handling', () => {
    it('should call _syncVectorClockToPfapi for WebDAV provider', async () => {
      configSubject.next(createMockSyncConfig(SyncProviderId.WebDAV));
      mockOpLogStore.getVectorClockEntry.and.returnValue(
        Promise.resolve({
          clock: { clientA: 5 },
          lastUpdate: Date.now(),
        }),
      );

      await service.sync();

      // Should have loaded and saved meta model for vector clock sync
      expect(mockLegacyPfDb.loadMetaModel).toHaveBeenCalled();
      expect(mockLegacyPfDb.saveMetaModel).toHaveBeenCalled();
    });

    it('should call _syncVectorClockToPfapi for Dropbox provider', async () => {
      configSubject.next(createMockSyncConfig(SyncProviderId.Dropbox));
      mockOpLogStore.getVectorClockEntry.and.returnValue(
        Promise.resolve({
          clock: { clientA: 5 },
          lastUpdate: Date.now(),
        }),
      );

      await service.sync();

      expect(mockLegacyPfDb.loadMetaModel).toHaveBeenCalled();
      expect(mockLegacyPfDb.saveMetaModel).toHaveBeenCalled();
    });

    it('should NOT call _syncVectorClockToPfapi for SuperSync provider', async () => {
      configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));

      await service.sync();

      // Should NOT have tried to sync vector clock for SuperSync
      expect(mockLegacyPfDb.saveMetaModel).not.toHaveBeenCalled();
    });

    it('should return InSync when provider does not support operation sync', async () => {
      mockWrappedProvider.getOperationSyncCapable.and.returnValue(Promise.resolve(null));

      const result = await service.sync();

      expect(result).toBe(SyncStatus.InSync);
      expect(mockSyncService.downloadRemoteOps).not.toHaveBeenCalled();
      expect(mockSyncService.uploadPendingOps).not.toHaveBeenCalled();
    });
  });

  describe('_sync() - Provider switch detection', () => {
    it('should pass forceFromSeq0 when provider has changed', async () => {
      mockProviderManager.getLastSyncedProviderId.and.returnValue(SyncProviderId.Dropbox);
      configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));

      await service.sync();

      expect(mockSyncService.downloadRemoteOps).toHaveBeenCalledWith(
        mockSyncCapableProvider,
        { forceFromSeq0: true, isNeverSynced: false },
      );
    });

    it('should NOT pass forceFromSeq0 when provider is the same', async () => {
      mockProviderManager.getLastSyncedProviderId.and.returnValue(
        SyncProviderId.SuperSync,
      );
      configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));

      await service.sync();

      expect(mockSyncService.downloadRemoteOps).toHaveBeenCalledWith(
        mockSyncCapableProvider,
        { forceFromSeq0: undefined, isNeverSynced: false },
      );
    });

    it('should NOT pass forceFromSeq0 on first-ever sync (no last synced provider)', async () => {
      mockProviderManager.getLastSyncedProviderId.and.returnValue(null);

      await service.sync();

      expect(mockSyncService.downloadRemoteOps).toHaveBeenCalledWith(
        mockSyncCapableProvider,
        { forceFromSeq0: undefined, isNeverSynced: false },
      );
    });

    it('should update lastSyncedProviderId after successful download', async () => {
      configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));

      await service.sync();

      expect(mockProviderManager.setLastSyncedProviderId).toHaveBeenCalledWith(
        SyncProviderId.SuperSync,
      );
    });

    it('should NOT update lastSyncedProviderId when download is cancelled', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'cancelled' as const,
        }),
      );

      await service.sync();

      expect(mockProviderManager.setLastSyncedProviderId).not.toHaveBeenCalled();
    });
  });

  describe('_sync() - Sync flow', () => {
    it('should download before upload', async () => {
      const callOrder: string[] = [];
      mockSyncService.downloadRemoteOps.and.callFake(async () => {
        callOrder.push('download');
        return { kind: 'no_new_ops' as const };
      });
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        callOrder.push('upload');
        return {
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        };
      });

      await service.sync();

      expect(callOrder).toEqual(['download', 'upload']);
    });

    it('should re-upload when localWinOpsCreated > 0 from download', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'ops_processed' as const,
          newOpsCount: 5,
          localWinOpsCreated: 3, // LWW created 3 local-win ops
        }),
      );
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 3,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        }),
      );

      await service.sync();

      // Upload should be called twice: initial + re-upload for LWW ops
      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
    });

    it('should re-upload when localWinOpsCreated > 0 from upload', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'no_new_ops' as const,
        }),
      );
      // First upload returns localWinOpsCreated > 0
      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        if (uploadCallCount === 1) {
          return {
            kind: 'completed' as const,
            uploadedCount: 2,
            piggybackedOpsCount: 0,
            localWinOpsCreated: 2, // LWW created ops from piggybacked
            permanentRejectionCount: 0,
            hasMorePiggyback: false,
            rejectedOps: [],
          };
        }
        return {
          kind: 'completed' as const,
          uploadedCount: 2,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        };
      });

      await service.sync();

      // Upload should be called twice
      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
    });

    it('should NOT re-upload when no localWinOpsCreated', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'ops_processed' as const,
          newOpsCount: 5,
          localWinOpsCreated: 0,
        }),
      );
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 3,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        }),
      );

      await service.sync();

      // Upload should be called only once
      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(1);
    });
  });

  describe('Status handling', () => {
    it('should set setSyncStatus IN_SYNC after successful sync', async () => {
      await service.sync();

      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
    });

    it('should NOT set IN_SYNC when error occurs', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new Error('Network error')),
      );

      await service.sync();

      // setSyncStatus is called with 'SYNCING' at start, but should NOT be called with 'IN_SYNC' on error
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    // Issue #7330: post-sync state validation failure must not be reported
    // as IN_SYNC. After the latch refactor, validation failure is signalled
    // via SyncSessionValidationService — the wrapper reads it once before
    // claiming IN_SYNC. Tests below simulate validation failure by flipping
    // the latch from inside a mocked sync call (mirroring what
    // RemoteOpsProcessingService.validateAfterSync does in production).
    it('should set ERROR (not IN_SYNC) when validation latch is flipped during download', async () => {
      const latch = TestBed.inject(SyncSessionValidationService);
      mockSyncService.downloadRemoteOps.and.callFake(async () => {
        latch.setFailed();
        return {
          kind: 'ops_processed' as const,
          newOpsCount: 3,
          localWinOpsCreated: 0,
        };
      });

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    it('should set ERROR (not IN_SYNC) when validation latch is flipped during upload', async () => {
      const latch = TestBed.inject(SyncSessionValidationService);
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        latch.setFailed();
        return {
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 2,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        };
      });

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    // Issue #7330 follow-up: a retry-pass piggybacked download can flip the
    // latch — the wrapper must still report ERROR.
    it('should set ERROR (not IN_SYNC) when validation latch is flipped during LWW re-upload', async () => {
      const latch = TestBed.inject(SyncSessionValidationService);
      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        if (uploadCallCount === 1) {
          // Initial upload returns localWinOpsCreated to enter the retry loop.
          return {
            kind: 'completed' as const,
            uploadedCount: 1,
            piggybackedOpsCount: 0,
            localWinOpsCreated: 2,
            permanentRejectionCount: 0,
            hasMorePiggyback: false,
            rejectedOps: [],
          };
        }
        // Retry pass flips the latch (simulating validation failure during
        // a piggybacked download triggered by uploadPendingOps).
        latch.setFailed();
        return {
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 1,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        };
      });

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    // #7521 follow-up: when re-upload retries exhaust AND the latch is
    // flipped, prefer ERROR over UNKNOWN_OR_CHANGED — validation failure is
    // a more serious signal than unuploaded ops.
    it('should set ERROR (not UNKNOWN_OR_CHANGED) when retries exhaust AND latch is flipped', async () => {
      const latch = TestBed.inject(SyncSessionValidationService);
      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        if (uploadCallCount === 2) {
          // One retry flips the latch; subsequent retries don't, but the
          // latch persists for the rest of the session.
          latch.setFailed();
        }
        return {
          kind: 'completed' as const,
          uploadedCount: 1,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 1,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        };
      });

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    // #7330 follow-up: when retries exhaust AND the initial download flipped
    // the latch (before the retry loop), the wrapper must still report ERROR.
    it('should set ERROR (not UNKNOWN_OR_CHANGED) when retries exhaust AND initial download flipped the latch', async () => {
      const latch = TestBed.inject(SyncSessionValidationService);
      mockSyncService.downloadRemoteOps.and.callFake(async () => {
        latch.setFailed();
        return {
          kind: 'ops_processed' as const,
          newOpsCount: 3,
          localWinOpsCreated: 0,
        };
      });
      mockSyncService.uploadPendingOps.and.callFake(async () => ({
        kind: 'completed' as const,
        uploadedCount: 1,
        piggybackedOpsCount: 0,
        localWinOpsCreated: 1,
        permanentRejectionCount: 0,
        hasMorePiggyback: false,
        rejectedOps: [],
      }));

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
    });

    // #7330: the USE_REMOTE conflict-resolution path returns
    // `kind: 'no_new_ops'` after applying remote state. If validation
    // failed during that apply, the latch is flipped — the wrapper must
    // surface this as ERROR rather than IN_SYNC.
    it('should set ERROR when downloadRemoteOps returns no_new_ops AND latch is flipped', async () => {
      const latch = TestBed.inject(SyncSessionValidationService);
      mockSyncService.downloadRemoteOps.and.callFake(async () => {
        latch.setFailed();
        return { kind: 'no_new_ops' as const };
      });

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    it('should set ERROR and return HANDLED_ERROR when upload has rejected ops with "Payload too complex"', async () => {
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 1,
          hasMorePiggyback: false,
          rejectedOps: [{ opId: 'test-op', error: 'Payload too complex (max depth 50)' }],
        }),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    it('should set ERROR and return HANDLED_ERROR when upload has rejected ops with "Payload too large"', async () => {
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 1,
          hasMorePiggyback: false,
          rejectedOps: [{ opId: 'test-op', error: 'Payload too large' }],
        }),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    it('should set ERROR for non-payload rejected ops', async () => {
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 1,
          hasMorePiggyback: false,
          rejectedOps: [{ opId: 'test-op', error: 'Some other rejection' }],
        }),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
    });

    it('should set IN_SYNC when some ops uploaded and none rejected', async () => {
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 5,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        }),
      );

      const result = await service.sync();

      // 5 ops uploaded → data changed this sync (discussion #7196)
      expect(result).toBe(SyncStatus.UpdateRemote);
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
    });

    it('should set ERROR when multiple ops rejected', async () => {
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 3,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 2,
          hasMorePiggyback: false,
          rejectedOps: [
            { opId: 'op1', error: 'Conflict' },
            { opId: 'op2', error: 'Validation failed' },
          ],
        }),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
    });

    it('should set IN_SYNC when uploadResult is blocked_fresh_client', async () => {
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({ kind: 'blocked_fresh_client' as const }),
      );

      const result = await service.sync();

      expect(result).toBe(SyncStatus.InSync);
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
    });

    it('should set IN_SYNC when permanentRejectionCount is 0 even with empty rejectedOps array', async () => {
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        }),
      );

      const result = await service.sync();

      expect(result).toBe(SyncStatus.InSync);
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
    });
  });

  describe('Error handling', () => {
    it('should handle PotentialCorsError with snack message', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new PotentialCorsError('https://example.com')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          type: 'ERROR',
        }),
      );
    });

    it('should handle NetworkUnavailableSPError with WARNING snackbar when user-triggered', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new NetworkUnavailableSPError()),
      );

      const result = await service.sync(true);

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.NETWORK_ERROR,
          type: 'WARNING',
        }),
      );
    });

    it('should handle NetworkUnavailableSPError silently for automatic syncs', async () => {
      // The auto-sync fired on Android resume hits a not-yet-ready network and
      // throws this transient error; the next cycle retries, so no snack should
      // flash for the user (regression guard for the resume "network" snack).
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new NetworkUnavailableSPError()),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      expect(mockSnackService.open).not.toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.NETWORK_ERROR,
        }),
      );
    });

    it('should silence a generic transient network error (file-based providers) for automatic syncs', async () => {
      // File-based providers (Dropbox/WebDAV) do NOT throw NetworkUnavailableSPError;
      // a resume-time connectivity failure surfaces as a generic Error (here the
      // Android UnknownHostException message). isTransientNetworkError() routes it
      // to the same silent-for-automatic path so the resume snack is gone
      // regardless of provider — not just SuperSync.
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(
          new Error('Unable to resolve host "example.com": No address associated'),
        ),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      // Neither the network WARNING nor the catch-all ERROR snack should fire.
      expect(mockSnackService.open).not.toHaveBeenCalled();
    });

    it('should show the WARNING snack for a generic transient network error when user-triggered', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(
          new Error('Unable to resolve host "example.com": No address associated'),
        ),
      );

      const result = await service.sync(true);

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.NETWORK_ERROR,
          type: 'WARNING',
        }),
      );
    });

    it('should treat native WebDAV timeout codes as transient network errors', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(
          new WebDavNativeRequestError('Network error: Request timeout', 'TIMEOUT_ERROR'),
        ),
      );

      const result = await service.sync(true);

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.NETWORK_ERROR,
          type: 'WARNING',
        }),
      );
      expect(mockSnackService.open).not.toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.TIMEOUT_ERROR,
        }),
      );
    });

    it('should surface a timeout error for user-triggered syncs', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new Error('Request timeout after 90s')),
      );

      const result = await service.sync(true);

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.TIMEOUT_ERROR,
        }),
      );
    });

    it('should silence a timeout error for automatic syncs', async () => {
      // A timeout is self-healing like a transient network error; an automatic
      // resume sync should not flash the "try again" snack nobody is waiting on.
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new Error('Request timeout after 90s')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).not.toHaveBeenCalled();
    });

    it('should silence a server 504 / gateway timeout for automatic syncs', async () => {
      // 504/gateway-timeout is matched by _isTimeoutError. Pin that it routes to
      // the (now-silenced-for-automatic) timeout branch rather than the catch-all
      // ERROR snack, so a future refactor that re-classifies 504 is caught.
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new Error('HTTP 504 Gateway Timeout')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).not.toHaveBeenCalled();
    });

    it('should surface a lock-acquisition timeout for user-triggered syncs', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new LockAcquisitionTimeoutError('sp_op_log', 30000)),
      );

      const result = await service.sync(true);

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.LOCK_TIMEOUT_ERROR,
        }),
      );
    });

    it('should silence a lock-acquisition timeout for automatic syncs', async () => {
      // #7562: a lock timeout is self-healing (the next cycle retries once the
      // holder frees) and since #8306 it no longer wedges the write queue, so an
      // automatic resume/interval sync should not flash the "try again" snack.
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new LockAcquisitionTimeoutError('sp_op_log', 30000)),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).not.toHaveBeenCalled();
    });

    it('should NOT treat a raw HTTP-status Error as a network error', async () => {
      // Regression guard: an error whose message contains "500"/"Internal Server
      // Error" must NOT be classified as a transient *network* error
      // (isTransientNetworkError checks connectivity phrases, not server status),
      // so it falls through to the catch-all ERROR branch and is surfaced.
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new Error('HTTP 500 Internal Server Error — db down')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockSnackService.open).not.toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.NETWORK_ERROR,
        }),
      );
    });

    it('should handle AuthFailSPError with config dialog action', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new AuthFailSPError()),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockSuperSyncStatusService.clearScope).toHaveBeenCalled();
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          type: 'ERROR',
          actionFn: jasmine.any(Function),
        }),
      );
    });

    it('should handle MissingCredentialsSPError with config dialog action', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new MissingCredentialsSPError('Dropbox no token')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockSuperSyncStatusService.clearScope).toHaveBeenCalled();
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          type: 'ERROR',
          actionFn: jasmine.any(Function),
        }),
      );
    });

    it('should handle MissingRefreshTokenAPIError with config dialog action', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new MissingRefreshTokenAPIError()),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockSuperSyncStatusService.clearScope).toHaveBeenCalled();
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          type: 'ERROR',
          actionFn: jasmine.any(Function),
        }),
      );
    });

    it('should NOT call clearAuthCredentials on first AuthFailSPError for SuperSync', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new AuthFailSPError()),
      );

      await service.sync();

      expect(mockProviderManager.clearAuthCredentials).not.toHaveBeenCalled();
    });

    it('should NOT call clearAuthCredentials on second consecutive AuthFailSPError for SuperSync', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new AuthFailSPError()),
      );

      await service.sync();
      await service.sync();

      expect(mockProviderManager.clearAuthCredentials).not.toHaveBeenCalled();
    });

    it('should call clearAuthCredentials on third consecutive AuthFailSPError for SuperSync', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new AuthFailSPError()),
      );

      await service.sync();
      await service.sync();
      await service.sync();

      expect(mockProviderManager.clearAuthCredentials).toHaveBeenCalledWith(
        SyncProviderId.SuperSync,
      );
    });

    it('should reset auth failure counter after successful sync', async () => {
      // Fail twice
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new AuthFailSPError()),
      );
      await service.sync();
      await service.sync();
      expect(mockProviderManager.clearAuthCredentials).not.toHaveBeenCalled();

      // Succeed once (reset counter)
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({ kind: 'no_new_ops' as const }),
      );
      await service.sync();

      // Fail once more — should NOT clear (counter was reset)
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new AuthFailSPError()),
      );
      mockProviderManager.clearAuthCredentials.calls.reset();
      await service.sync();

      expect(mockProviderManager.clearAuthCredentials).not.toHaveBeenCalled();
    });

    it('should reset auth failure counter when a non-auth error occurs between auth errors', async () => {
      // Fail twice with auth error
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new AuthFailSPError()),
      );
      await service.sync();
      await service.sync();
      expect(mockProviderManager.clearAuthCredentials).not.toHaveBeenCalled();

      // Non-auth error resets the counter
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new Error('network timeout')),
      );
      await service.sync();

      // Next two auth failures should NOT clear (counter was reset by non-auth error)
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new AuthFailSPError()),
      );
      mockProviderManager.clearAuthCredentials.calls.reset();
      await service.sync();
      await service.sync();

      expect(mockProviderManager.clearAuthCredentials).not.toHaveBeenCalled();
    });

    it('should call clearAuthCredentials on MissingCredentialsSPError', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new MissingCredentialsSPError('no token')),
      );

      await service.sync();

      expect(mockProviderManager.clearAuthCredentials).toHaveBeenCalledWith(
        SyncProviderId.SuperSync,
      );
    });

    it('should call clearAuthCredentials on MissingRefreshTokenAPIError', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new MissingRefreshTokenAPIError()),
      );

      await service.sync();

      expect(mockProviderManager.clearAuthCredentials).toHaveBeenCalledWith(
        SyncProviderId.SuperSync,
      );
    });

    it('should still show snack when clearAuthCredentials throws', async () => {
      mockProviderManager.clearAuthCredentials.and.returnValue(
        Promise.reject(new Error('IndexedDB error')),
      );
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new MissingCredentialsSPError('no token')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          type: 'ERROR',
          actionFn: jasmine.any(Function),
        }),
      );
    });

    it('should handle SyncAlreadyInProgressError silently', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new SyncAlreadyInProgressError()),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      // Should NOT show snack for this error
      expect(mockSnackService.open).not.toHaveBeenCalled();
    });

    it('should handle JsonParseError with force-overwrite action and corrupted-data message (#5574, #4616)', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new JsonParseError(new SyntaxError('Unexpected end of JSON'), '')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.ERROR_REMOTE_FILE_CORRUPTED,
          type: 'ERROR',
          actionFn: jasmine.any(Function),
          actionStr: jasmine.any(String),
        }),
      );
    });

    it('should handle SyncDataCorruptedError with version-mismatch message (no force-overwrite)', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(
          new SyncDataCorruptedError('Unsupported version: 1', 'sync-data.json'),
        ),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      // Must show the version-mismatch message (not generic "corrupted data")
      // Must NOT offer force-upload: remote may be a newer version from another client
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.ERROR_SYNC_VERSION_MISMATCH,
          type: 'ERROR',
        }),
      );
      const callArgs = mockSnackService.open.calls.mostRecent().args[0];
      expect(callArgs['actionFn']).toBeUndefined();
    });

    it('should handle SyncDataCorruptedError for newer remote version (no force-overwrite, same message)', async () => {
      // version 3 > FILE_VERSION 2 — remote is from a future app version
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(
          new SyncDataCorruptedError('Unsupported version: 3', 'sync-data.json'),
        ),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          msg: T.F.SYNC.S.ERROR_SYNC_VERSION_MISMATCH,
          type: 'ERROR',
        }),
      );
      // No force-upload button — overwriting a newer remote would destroy data
      const callArgs = mockSnackService.open.calls.mostRecent().args[0];
      expect(callArgs['actionFn']).toBeUndefined();
    });

    describe('LocalDataConflictError handling', () => {
      beforeEach(() => {
        mockOpLogStore.getVectorClockEntry.and.returnValue(
          Promise.resolve({
            clock: { clientA: 5 },
            lastUpdate: Date.now(),
          }),
        );
      });

      it('should catch LocalDataConflictError and open conflict dialog', async () => {
        const conflictError = new LocalDataConflictError(
          3, // unsyncedCount
          { tasks: [{ id: 'remote-task' }] }, // remoteSnapshotState
          { clientB: 5 }, // remoteVectorClock
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        // Mock dialog to return USE_LOCAL
        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_LOCAL'),
        } as any);

        mockSyncService.forceUploadLocalState = jasmine
          .createSpy('forceUploadLocalState')
          .and.resolveTo();

        await service.sync();

        // Should open conflict dialog
        expect(mockMatDialog.open).toHaveBeenCalled();
      });

      it('should call forceUploadLocalState when user chooses USE_LOCAL', async () => {
        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_LOCAL'),
        } as any);

        mockSyncService.forceUploadLocalState = jasmine
          .createSpy('forceUploadLocalState')
          .and.resolveTo();

        const result = await service.sync();

        expect(mockSyncService.forceUploadLocalState).toHaveBeenCalledWith(
          mockSyncCapableProvider,
        );
        expect(result).toBe(SyncStatus.InSync);
      });

      it('should call forceDownloadRemoteState when user chooses USE_REMOTE', async () => {
        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_REMOTE'),
        } as any);

        mockSyncService.forceDownloadRemoteState = jasmine
          .createSpy('forceDownloadRemoteState')
          .and.resolveTo();

        const result = await service.sync();

        expect(mockSyncService.forceDownloadRemoteState).toHaveBeenCalledWith(
          mockSyncCapableProvider,
        );
        expect(result).toBe(SyncStatus.InSync);
      });

      it('should return HANDLED_ERROR when user cancels conflict dialog', async () => {
        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        // User cancels dialog (returns undefined)
        mockMatDialog.open.and.returnValue({
          afterClosed: () => of(undefined),
        } as any);

        const result = await service.sync();

        expect(result).toBe('HANDLED_ERROR');
        expect(mockSnackService.open).toHaveBeenCalled();
        // Issue #7339: previously, filter(undefined) on the dialog stream caused
        // firstValueFrom() to throw EmptyError, which surfaced as the generic
        // ERROR snack. After the fix, an undefined close (e.g., iOS app
        // lifecycle killing the dialog) flows through as a clean cancellation.
        expect(mockSnackService.open).not.toHaveBeenCalledWith(
          jasmine.objectContaining({ type: 'ERROR' }),
        );
      });

      it('should return HANDLED_ERROR when forceUploadLocalState fails', async () => {
        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_LOCAL'),
        } as any);

        mockSyncService.forceUploadLocalState = jasmine
          .createSpy('forceUploadLocalState')
          .and.rejectWith(new Error('Upload failed'));

        const result = await service.sync();

        expect(result).toBe('HANDLED_ERROR');
        expect(mockSnackService.open).toHaveBeenCalledWith(
          jasmine.objectContaining({
            type: 'ERROR',
          }),
        );
      });

      // GHSA-9544-hjjr-fg8h: USE_LOCAL force-uploads, which refuses to send
      // plaintext when the key is missing. Route to the enter-password recovery
      // dialog instead of a dead-end error snack.
      it('should open the enter-password dialog (not an error snack) when USE_LOCAL hits a missing key', async () => {
        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_LOCAL'),
        } as any);

        mockSyncService.forceUploadLocalState = jasmine
          .createSpy('forceUploadLocalState')
          .and.rejectWith(new EncryptNoPasswordError('key missing'));

        const result = await service.sync();

        expect(result).toBe('HANDLED_ERROR');
        expect(mockMatDialog.open).toHaveBeenCalledWith(
          DialogEnterEncryptionPasswordComponent,
          jasmine.anything(),
        );
        expect(mockSnackService.open).not.toHaveBeenCalledWith(
          jasmine.objectContaining({ type: 'ERROR' }),
        );
      });

      // Issue #7330: even when forceDownloadRemoteState succeeds, if it
      // flips the session-validation latch, the wrapper must not claim IN_SYNC.
      it('should return HANDLED_ERROR with ERROR status when forceDownloadRemoteState flips the latch', async () => {
        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_REMOTE'),
        } as any);

        const latch = TestBed.inject(SyncSessionValidationService);
        mockSyncService.forceDownloadRemoteState = jasmine
          .createSpy('forceDownloadRemoteState')
          .and.callFake(async () => {
            latch.setFailed();
          });

        const result = await service.sync();

        expect(result).toBe('HANDLED_ERROR');
        expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      });

      it('should return HANDLED_ERROR when forceDownloadRemoteState fails', async () => {
        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_REMOTE'),
        } as any);

        mockSyncService.forceDownloadRemoteState = jasmine
          .createSpy('forceDownloadRemoteState')
          .and.rejectWith(new Error('Download failed'));

        const result = await service.sync();

        expect(result).toBe('HANDLED_ERROR');
        expect(mockSnackService.open).toHaveBeenCalledWith(
          jasmine.objectContaining({
            type: 'ERROR',
          }),
        );
      });

      it('should return HANDLED_ERROR when provider becomes unavailable during resolution', async () => {
        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_LOCAL'),
        } as any);

        // First call returns provider (for initial sync), second call returns null (during resolution)
        let callCount = 0;
        mockWrappedProvider.getOperationSyncCapable.and.callFake(() => {
          callCount++;
          if (callCount === 1) {
            return Promise.resolve(mockSyncCapableProvider);
          }
          return Promise.resolve(null);
        });

        const result = await service.sync();

        expect(result).toBe('HANDLED_ERROR');
      });

      it('should call startWaiting before showing dialog and stop after resolution', async () => {
        const stopWaitingSpy = jasmine.createSpy('stopWaiting');
        mockUserInputWaitState.startWaiting.and.returnValue(stopWaitingSpy);

        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_LOCAL'),
        } as any);

        mockSyncService.forceUploadLocalState = jasmine
          .createSpy('forceUploadLocalState')
          .and.resolveTo();

        await service.sync();

        expect(mockUserInputWaitState.startWaiting).toHaveBeenCalledWith(
          'local-data-conflict',
        );
        expect(stopWaitingSpy).toHaveBeenCalled();
      });

      it('should call stopWaiting even when resolution fails', async () => {
        const stopWaitingSpy = jasmine.createSpy('stopWaiting');
        mockUserInputWaitState.startWaiting.and.returnValue(stopWaitingSpy);

        const conflictError = new LocalDataConflictError(
          2,
          { tasks: [] },
          { clientB: 3 },
        );
        mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));

        mockMatDialog.open.and.returnValue({
          afterClosed: () => of('USE_LOCAL'),
        } as any);

        mockSyncService.forceUploadLocalState = jasmine
          .createSpy('forceUploadLocalState')
          .and.rejectWith(new Error('Upload failed'));

        await service.sync();

        // stopWaiting should be called even on error (finally block)
        expect(stopWaitingSpy).toHaveBeenCalled();
      });
    });

    it('should handle permission errors with appropriate message', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new Error('EACCES: permission denied')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          type: 'ERROR',
        }),
      );
    });

    it('should handle unknown errors with generic snack', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.reject(new Error('Some unexpected error')),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockSnackService.open).toHaveBeenCalledWith(
        jasmine.objectContaining({
          type: 'ERROR',
        }),
      );
    });

    it('should treat UploadRevToMatchMismatchAPIError as transient: set UNKNOWN_OR_CHANGED, no error snackbar', async () => {
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.reject(
          new UploadRevToMatchMismatchAPIError('Concurrent upload detected'),
        ),
      );

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      expect(mockSnackService.open).not.toHaveBeenCalled();
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('ERROR');
    });
  });

  describe('isSyncInProgressSync()', () => {
    it('should return false initially', () => {
      expect(service.isSyncInProgressSync()).toBe(false);
    });
  });

  describe('isEncryptionOperationInProgress', () => {
    it('should return false initially', () => {
      expect(service.isEncryptionOperationInProgress).toBe(false);
    });

    it('should return true during runWithSyncBlocked execution', async () => {
      let capturedValue = false;

      await service.runWithSyncBlocked(async () => {
        capturedValue = service.isEncryptionOperationInProgress;
      });

      expect(capturedValue).toBe(true);
    });

    it('should return false after runWithSyncBlocked completes', async () => {
      await service.runWithSyncBlocked(async () => {
        // do nothing
      });

      expect(service.isEncryptionOperationInProgress).toBe(false);
    });

    it('should return false after runWithSyncBlocked throws', async () => {
      try {
        await service.runWithSyncBlocked(async () => {
          throw new Error('Test error');
        });
      } catch {
        // expected
      }

      expect(service.isEncryptionOperationInProgress).toBe(false);
    });
  });

  describe('_promptSuperSyncEncryptionIfNeeded() — post-sync encryption prompt', () => {
    let privateCfgLoad: jasmine.Spy;
    // Drive openDialogs through a getter over a closure variable so mutations are
    // reliably observed by the service (Jasmine property-bag values are not).
    let openDialogs: unknown[];

    beforeEach(() => {
      openDialogs = [];
      Object.defineProperty(mockMatDialog, 'openDialogs', {
        configurable: true,
        get: () => openDialogs,
      });
      privateCfgLoad = jasmine
        .createSpy('privateCfg.load')
        .and.resolveTo({ isEncryptionEnabled: false, encryptKey: '' });
      mockProviderManager.getActiveProvider.and.returnValue({
        id: SyncProviderId.SuperSync,
        privateCfg: { load: privateCfgLoad },
      } as any);
      mockMatDialog.open.and.returnValue({ afterClosed: () => of(undefined) } as any);
      // Arm the one-shot setup-sync flag the prompt consumes; without it the method
      // early-returns (the migration banner owns established accounts) and never
      // evaluates the dialog logic these tests cover.
      service.markPromptEncryptionAfterSetupSync();
    });

    const callPrompt = (): Promise<void> =>
      (service as any)._promptSuperSyncEncryptionIfNeeded();

    const waitMs = (ms: number): Promise<void> =>
      new Promise((resolve) => setTimeout(resolve, ms));

    it('opens the encryption dialog immediately when no other dialog is open', async () => {
      openDialogs = [];

      await callPrompt();

      expect(mockMatDialog.open).toHaveBeenCalled();
    });

    it('defers the prompt while a dialog is open, then opens it once it closes (#8670)', async () => {
      // Simulate the sync-config dialog still playing its close animation: with the
      // E2EE-mandatory upload guard the first sync now completes almost instantly,
      // so the prompt fires while the config dialog is still in openDialogs.
      openDialogs = [{}];

      const done = callPrompt();
      // Prompt must NOT open while the dialog is still there (several poll cycles)…
      await waitMs(250);
      expect(mockMatDialog.open).not.toHaveBeenCalled();

      // …but must open once the dialog finishes closing (never dropped).
      openDialogs = [];
      await done;
      expect(mockMatDialog.open).toHaveBeenCalled();
    });

    it('does not prompt if encryption gets configured while waiting for the dialog to close', async () => {
      openDialogs = [{}];

      const done = callPrompt();
      await waitMs(250);

      // The open dialog configured encryption itself (e.g. an enter-password flow);
      // once it closes the re-check must see the key and skip the enable prompt.
      privateCfgLoad.and.resolveTo({ isEncryptionEnabled: true, encryptKey: 'key' });
      openDialogs = [];
      await done;

      expect(mockMatDialog.open).not.toHaveBeenCalled();
    });

    it('skips when encryption is already enabled', async () => {
      privateCfgLoad.and.resolveTo({ isEncryptionEnabled: true, encryptKey: 'key' });
      openDialogs = [];

      await callPrompt();

      expect(mockMatDialog.open).not.toHaveBeenCalled();
    });

    it('does not prompt if the active provider is no longer SuperSync after the wait', async () => {
      openDialogs = [{}];

      const done = callPrompt();
      await waitMs(250);

      // The closing dialog switched provider / disabled SuperSync while we waited;
      // the disableClose setup dialog must not open for a stale provider.
      configSubject.next(createMockSyncConfig(SyncProviderId.Dropbox));
      openDialogs = [];
      await done;

      expect(mockMatDialog.open).not.toHaveBeenCalled();
    });
  });

  describe('runWithSyncBlocked()', () => {
    it('should execute the operation and return its result', async () => {
      const result = await service.runWithSyncBlocked(async () => {
        return 'test-result';
      });

      expect(result).toBe('test-result');
    });

    it('should propagate errors from the operation', async () => {
      const testError = new Error('Test operation error');

      await expectAsync(
        service.runWithSyncBlocked(async () => {
          throw testError;
        }),
      ).toBeRejectedWith(testError);
    });

    it('should block sync during operation', async () => {
      let syncResultDuringOperation: SyncStatus | 'HANDLED_ERROR' | undefined;

      await service.runWithSyncBlocked(async () => {
        // Try to sync during encryption operation
        syncResultDuringOperation = await service.sync();
      });

      // Sync should have been blocked and returned HANDLED_ERROR
      expect(syncResultDuringOperation).toBe('HANDLED_ERROR');
    });

    it('should allow sync after operation completes', async () => {
      await service.runWithSyncBlocked(async () => {
        // do nothing
      });

      // Sync should work after encryption operation completes
      const result = await service.sync();

      expect(result).toBe(SyncStatus.InSync);
    });

    it('should wait for ongoing sync to complete before starting operation', async () => {
      const callOrder: string[] = [];

      // Start a sync that takes a bit
      let syncResolve: () => void;
      const syncPromise = new Promise<void>((resolve) => {
        syncResolve = resolve;
      });

      mockSyncService.downloadRemoteOps.and.callFake(async () => {
        callOrder.push('sync-download-start');
        await syncPromise;
        callOrder.push('sync-download-end');
        return { kind: 'no_new_ops' as const };
      });

      // Start sync
      const syncCall = service.sync();

      // Give sync time to start
      await new Promise((r) => setTimeout(r, 10));

      // Start encryption operation - should wait for sync
      const encryptionOpPromise = service.runWithSyncBlocked(async () => {
        callOrder.push('encryption-op');
      });

      // Let sync complete
      syncResolve!();
      await syncCall;
      await encryptionOpPromise;

      // Encryption operation should have waited for sync to complete
      expect(callOrder).toEqual([
        'sync-download-start',
        'sync-download-end',
        'encryption-op',
      ]);
    });
  });

  describe('sync() with encryption operation blocking', () => {
    it('should return HANDLED_ERROR when encryption operation is in progress', async () => {
      // Manually set the flag (simulating runWithSyncBlocked is active)
      service['_isEncryptionOperationInProgress$'].next(true);

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');

      // Clean up
      service['_isEncryptionOperationInProgress$'].next(false);
    });

    it('should not call download or upload when encryption operation is in progress', async () => {
      service['_isEncryptionOperationInProgress$'].next(true);

      await service.sync();

      expect(mockSyncService.downloadRemoteOps).not.toHaveBeenCalled();
      expect(mockSyncService.uploadPendingOps).not.toHaveBeenCalled();

      // Clean up
      service['_isEncryptionOperationInProgress$'].next(false);
    });
  });

  describe('syncProviderId$', () => {
    it('should emit SyncProviderId from sync config', (done) => {
      configSubject.next(createMockSyncConfig(SyncProviderId.SuperSync));

      service.syncProviderId$.subscribe((providerId) => {
        expect(providerId).toBe(SyncProviderId.SuperSync);
        done();
      });
    });

    it('should return null for null sync provider', (done) => {
      configSubject.next(createMockSyncConfig(null));

      service.syncProviderId$.subscribe((providerId) => {
        expect(providerId).toBeNull();
        done();
      });
    });
  });

  describe('superSyncIsConfirmedInSync$', () => {
    let signalService: SyncWrapperService;
    let isConfirmedSignal: ReturnType<typeof signal<boolean>>;
    let signalConfigSubject: BehaviorSubject<any>;

    const createSignalMockConfig = (provider: SyncProviderId | null): { sync: any } => ({
      sync: {
        syncProvider: provider,
        syncInterval: 60000,
      },
    });

    const createServiceWithSignal = (initialValue: boolean): SyncWrapperService => {
      isConfirmedSignal = signal(initialValue);
      signalConfigSubject = new BehaviorSubject(
        createSignalMockConfig(SyncProviderId.SuperSync),
      );

      const signalMockSuperSyncStatusService = {
        isConfirmedInSync: isConfirmedSignal,
        hasNoPendingOps: signal(true), // When isConfirmedInSync is true, hasNoPendingOps is also true
        markRemoteChecked: jasmine.createSpy('markRemoteChecked'),
        clearScope: jasmine.createSpy('clearScope'),
        updatePendingOpsStatus: jasmine.createSpy('updatePendingOpsStatus'),
      };

      TestBed.resetTestingModule();
      TestBed.configureTestingModule({
        providers: [
          SyncWrapperService,
          { provide: SyncProviderManager, useValue: mockProviderManager },
          { provide: OperationLogSyncService, useValue: mockSyncService },
          { provide: WrappedProviderService, useValue: mockWrappedProvider },
          { provide: OperationLogStoreService, useValue: mockOpLogStore },
          { provide: LegacyPfDbService, useValue: mockLegacyPfDb },
          {
            provide: GlobalConfigService,
            useValue: { cfg$: signalConfigSubject.asObservable() },
          },
          { provide: TranslateService, useValue: mockTranslateService },
          { provide: MatDialog, useValue: mockMatDialog },
          { provide: SnackService, useValue: mockSnackService },
          { provide: DataInitService, useValue: mockDataInitService },
          { provide: ReminderService, useValue: mockReminderService },
          { provide: UserInputWaitStateService, useValue: mockUserInputWaitState },
          { provide: SuperSyncStatusService, useValue: signalMockSuperSyncStatusService },
        ],
      });

      return TestBed.inject(SyncWrapperService);
    };

    describe('with SuperSync provider', () => {
      it('should return true when SuperSyncStatusService.isConfirmedInSync is true', (done) => {
        signalService = createServiceWithSignal(true);
        signalConfigSubject.next(createSignalMockConfig(SyncProviderId.SuperSync));

        signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
          expect(isConfirmed).toBe(true);
          done();
        });
      });

      it('should return false when SuperSyncStatusService.isConfirmedInSync is false', (done) => {
        signalService = createServiceWithSignal(false);
        signalConfigSubject.next(createSignalMockConfig(SyncProviderId.SuperSync));

        signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
          expect(isConfirmed).toBe(false);
          done();
        });
      });
    });

    describe('with file-based providers', () => {
      it('should return false for WebDAV when status service returns false', (done) => {
        signalService = createServiceWithSignal(false);
        signalConfigSubject.next(createSignalMockConfig(SyncProviderId.WebDAV));

        signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
          expect(isConfirmed).toBe(false);
          done();
        });
      });

      it('should return true for WebDAV when status service returns true', (done) => {
        signalService = createServiceWithSignal(true);
        signalConfigSubject.next(createSignalMockConfig(SyncProviderId.WebDAV));

        signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
          expect(isConfirmed).toBe(true);
          done();
        });
      });

      it('should return false for Dropbox when status service returns false', (done) => {
        signalService = createServiceWithSignal(false);
        signalConfigSubject.next(createSignalMockConfig(SyncProviderId.Dropbox));

        signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
          expect(isConfirmed).toBe(false);
          done();
        });
      });

      it('should return true for Dropbox when status service returns true', (done) => {
        signalService = createServiceWithSignal(true);
        signalConfigSubject.next(createSignalMockConfig(SyncProviderId.Dropbox));

        signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
          expect(isConfirmed).toBe(true);
          done();
        });
      });

      it('should return false for LocalFile when status service returns false', (done) => {
        signalService = createServiceWithSignal(false);
        signalConfigSubject.next(createSignalMockConfig(SyncProviderId.LocalFile));

        signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
          expect(isConfirmed).toBe(false);
          done();
        });
      });

      it('should return true for LocalFile when status service returns true', (done) => {
        signalService = createServiceWithSignal(true);
        signalConfigSubject.next(createSignalMockConfig(SyncProviderId.LocalFile));

        signalService.superSyncIsConfirmedInSync$.subscribe((isConfirmed) => {
          expect(isConfirmed).toBe(true);
          done();
        });
      });
    });
  });

  describe('hasNoPendingOps$', () => {
    let signalService: SyncWrapperService;
    let hasNoPendingOpsSignal: ReturnType<typeof signal<boolean>>;

    const createServiceWithPendingOpsSignal = (
      hasNoPendingOps: boolean,
    ): SyncWrapperService => {
      hasNoPendingOpsSignal = signal(hasNoPendingOps);

      const signalMockSuperSyncStatusService = {
        isConfirmedInSync: signal(false),
        hasNoPendingOps: hasNoPendingOpsSignal,
        markRemoteChecked: jasmine.createSpy('markRemoteChecked'),
        clearScope: jasmine.createSpy('clearScope'),
        updatePendingOpsStatus: jasmine.createSpy('updatePendingOpsStatus'),
      };

      TestBed.resetTestingModule();
      TestBed.configureTestingModule({
        providers: [
          SyncWrapperService,
          { provide: SyncProviderManager, useValue: mockProviderManager },
          { provide: OperationLogSyncService, useValue: mockSyncService },
          { provide: WrappedProviderService, useValue: mockWrappedProvider },
          { provide: OperationLogStoreService, useValue: mockOpLogStore },
          { provide: LegacyPfDbService, useValue: mockLegacyPfDb },
          {
            provide: GlobalConfigService,
            useValue: { cfg$: configSubject.asObservable() },
          },
          { provide: TranslateService, useValue: mockTranslateService },
          { provide: MatDialog, useValue: mockMatDialog },
          { provide: SnackService, useValue: mockSnackService },
          { provide: DataInitService, useValue: mockDataInitService },
          { provide: ReminderService, useValue: mockReminderService },
          { provide: UserInputWaitStateService, useValue: mockUserInputWaitState },
          { provide: SuperSyncStatusService, useValue: signalMockSuperSyncStatusService },
        ],
      });

      return TestBed.inject(SyncWrapperService);
    };

    it('should return true when hasNoPendingOps signal is true', (done) => {
      signalService = createServiceWithPendingOpsSignal(true);

      signalService.hasNoPendingOps$.subscribe((hasNoPending) => {
        expect(hasNoPending).toBe(true);
        done();
      });
    });

    it('should return false when hasNoPendingOps signal is false', (done) => {
      signalService = createServiceWithPendingOpsSignal(false);

      signalService.hasNoPendingOps$.subscribe((hasNoPending) => {
        expect(hasNoPending).toBe(false);
        done();
      });
    });
  });

  describe('_isTimeoutError', () => {
    it('should detect timeout keyword in error message', () => {
      const timeoutError = new Error('Request timeout after 75s');
      expect(service['_isTimeoutError'](timeoutError)).toBe(true);
    });

    it('should detect 504 status code', () => {
      const error504 = new Error('504 Gateway Timeout');
      expect(service['_isTimeoutError'](error504)).toBe(true);
    });

    it('should detect gateway timeout phrase', () => {
      const gatewayError = new Error('Error: gateway timeout from proxy');
      expect(service['_isTimeoutError'](gatewayError)).toBe(true);
    });

    it('should be case insensitive', () => {
      const uppercaseError = new Error('REQUEST TIMEOUT');
      expect(service['_isTimeoutError'](uppercaseError)).toBe(true);

      const mixedCaseError = new Error('Gateway TIMEOUT occurred');
      expect(service['_isTimeoutError'](mixedCaseError)).toBe(true);
    });

    it('should not false-positive on network errors', () => {
      const networkError = new Error('Network error');
      expect(service['_isTimeoutError'](networkError)).toBe(false);
    });

    it('should not false-positive on auth errors', () => {
      const authError = new Error('401 Unauthorized');
      expect(service['_isTimeoutError'](authError)).toBe(false);
    });

    it('should not false-positive on generic errors', () => {
      const genericError = new Error('Something went wrong');
      expect(service['_isTimeoutError'](genericError)).toBe(false);
    });

    it('should handle non-Error objects', () => {
      expect(service['_isTimeoutError']('timeout string')).toBe(true);
      expect(service['_isTimeoutError']('regular error')).toBe(false);
    });

    it('should handle objects with toString()', () => {
      const errorObj = { toString: () => 'Error: timeout occurred' };
      expect(service['_isTimeoutError'](errorObj)).toBe(true);
    });
  });

  describe('_sync() - LWW retry loop limit', () => {
    it('should stop after MAX_LWW_REUPLOAD_RETRIES when upload always returns localWinOpsCreated', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'no_new_ops' as const,
        }),
      );
      // Upload always returns localWinOpsCreated: 2 (never resolves)
      mockSyncService.uploadPendingOps.and.returnValue(
        Promise.resolve({
          kind: 'completed' as const,
          uploadedCount: 2,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 2,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        }),
      );

      const result = await service.sync();

      // 1 initial upload + MAX_LWW_REUPLOAD_RETRIES retries
      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(
        1 + MAX_LWW_REUPLOAD_RETRIES,
      );
      // Should set UNKNOWN_OR_CHANGED since ops remain pending
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      // Should return UpdateRemote to signal that unuploaded ops remain
      expect(result).toBe(SyncStatus.UpdateRemote);
    });

    it('should exit early when retry returns localWinOpsCreated: 0', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'no_new_ops' as const,
        }),
      );

      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        return {
          kind: 'completed' as const,
          uploadedCount: 2,
          piggybackedOpsCount: 0,
          // First call returns 1, second call returns 0 -> exits loop
          localWinOpsCreated: uploadCallCount <= 1 ? 1 : 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        };
      });

      const result = await service.sync();

      // 1 initial upload + 1 retry (which returns 0) = 2 total
      // The retry returns 0 so no more retries needed
      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
      // ops were uploaded → data changed this sync (discussion #7196)
      expect(result).toBe(SyncStatus.UpdateRemote);
    });

    it('should pass the pre-sync never-synced snapshot into LWW re-upload retries', async () => {
      mockSyncService.hasSyncedOps.and.resolveTo(false);
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'no_new_ops' as const,
        }),
      );

      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        return {
          kind: 'completed' as const,
          uploadedCount: 1,
          piggybackedOpsCount: 0,
          localWinOpsCreated: uploadCallCount === 1 ? 1 : 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        };
      });

      await service.sync();

      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
      expect(mockSyncService.uploadPendingOps.calls.argsFor(0)[1]).toEqual({
        isNeverSynced: true,
      });
      expect(mockSyncService.uploadPendingOps.calls.argsFor(1)[1]).toEqual({
        isNeverSynced: true,
      });
    });

    it('should stop sync when an LWW re-upload is cancelled', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'no_new_ops' as const,
        }),
      );

      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        if (uploadCallCount === 1) {
          return {
            kind: 'completed' as const,
            uploadedCount: 1,
            piggybackedOpsCount: 0,
            localWinOpsCreated: 1,
            permanentRejectionCount: 0,
            hasMorePiggyback: false,
            rejectedOps: [],
          };
        }
        return { kind: 'cancelled' as const };
      });

      const result = await service.sync();

      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    it('should stop sync when an LWW re-upload has permanent rejections', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'no_new_ops' as const,
        }),
      );

      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        if (uploadCallCount === 1) {
          return {
            kind: 'completed' as const,
            uploadedCount: 1,
            piggybackedOpsCount: 0,
            localWinOpsCreated: 1,
            permanentRejectionCount: 0,
            hasMorePiggyback: false,
            rejectedOps: [],
          };
        }
        return {
          kind: 'completed' as const,
          uploadedCount: 0,
          piggybackedOpsCount: 0,
          localWinOpsCreated: 0,
          permanentRejectionCount: 1,
          hasMorePiggyback: false,
          rejectedOps: [{ opId: 'lww-op', error: 'Validation failed' }],
        };
      });

      const result = await service.sync();

      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    it('should stop sync when LWW re-upload is cancelled and the pending ops came from download', async () => {
      // downloadResult.localWinOpsCreated > 0 means LWW work originated from
      // the download path. The initial upload produces no LWW ops, so the
      // very first retry is what cancels — guard against a regression where
      // the cancel-path only triggers on upload-originated LWW work.
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'ops_processed' as const,
          newOpsCount: 5,
          localWinOpsCreated: 2,
        }),
      );

      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        if (uploadCallCount === 1) {
          return {
            kind: 'completed' as const,
            uploadedCount: 1,
            piggybackedOpsCount: 0,
            localWinOpsCreated: 0,
            permanentRejectionCount: 0,
            hasMorePiggyback: false,
            rejectedOps: [],
          };
        }
        return { kind: 'cancelled' as const };
      });

      const result = await service.sync();

      // initial upload + 1 retry that cancels = 2 calls, no further retries
      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
      expect(result).toBe('HANDLED_ERROR');
      expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
        'UNKNOWN_OR_CHANGED',
      );
      expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalledWith('IN_SYNC');
    });

    it('should treat blocked_fresh_client reupload result as 0 localWinOpsCreated and exit loop', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'no_new_ops' as const,
        }),
      );

      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        if (uploadCallCount === 1) {
          return {
            kind: 'completed' as const,
            uploadedCount: 1,
            piggybackedOpsCount: 0,
            localWinOpsCreated: 2,
            permanentRejectionCount: 0,
            hasMorePiggyback: false,
            rejectedOps: [],
          };
        }
        // Second call returns blocked_fresh_client (treated as 0 localWinOpsCreated)
        return { kind: 'blocked_fresh_client' as const };
      });

      const result = await service.sync();

      // 1 initial + 1 retry (returns blocked_fresh_client -> treated as 0) = 2 total
      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
      // first upload moved 1 op → data changed this sync (discussion #7196)
      expect(result).toBe(SyncStatus.UpdateRemote);
    });

    it('should enter while loop when both download and upload produce LWW ops', async () => {
      mockSyncService.downloadRemoteOps.and.returnValue(
        Promise.resolve({
          kind: 'ops_processed' as const,
          newOpsCount: 5,
          localWinOpsCreated: 2, // download produced LWW ops
        }),
      );

      let uploadCallCount = 0;
      mockSyncService.uploadPendingOps.and.callFake(async () => {
        uploadCallCount++;
        return {
          kind: 'completed' as const,
          uploadedCount: 3,
          piggybackedOpsCount: 0,
          // First upload also produces LWW ops, subsequent do not
          localWinOpsCreated: uploadCallCount === 1 ? 1 : 0,
          permanentRejectionCount: 0,
          hasMorePiggyback: false,
          rejectedOps: [],
        };
      });

      const result = await service.sync();

      // pendingLwwOps = download(2) + upload(1) = 3
      // Retry 1: upload returns 0 -> exits loop
      // Total uploads: 1 initial + 1 retry = 2
      expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(2);
      // 5 ops downloaded + 3 uploaded → data changed this sync (discussion #7196)
      expect(result).toBe(SyncStatus.UpdateRemote);
    });
  });
});
