import { DatePipe } from '@angular/common'
import {
  HttpHeaders,
  HttpResponse,
  provideHttpClient,
  withInterceptorsFromDi,
} from '@angular/common/http'
import {
  HttpTestingController,
  provideHttpClientTesting,
} from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import {
  ActivatedRoute,
  Router,
  RouterModule,
  convertToParamMap,
} from '@angular/router'
import {
  NgbDateStruct,
  NgbModal,
  NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { Subject, of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomFieldDataType } from 'src/app/data/custom-field'
import { DataType } from 'src/app/data/datatype'
import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
import {
  FILTER_CORRESPONDENT,
  FILTER_CREATED_AFTER,
  FILTER_CREATED_BEFORE,
  FILTER_DOCUMENT_TYPE,
  FILTER_FULLTEXT_MORELIKE,
  FILTER_HAS_TAGS_ALL,
  FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { ComponentRouterService } from 'src/app/services/component-router.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import {
  PdfZoomLevel,
  PdfZoomScale,
} from '../common/pdf-viewer/pdf-viewer.types'
import { DocumentDetailComponent } from './document-detail.component'

const doc: Document = {
  id: 3,
  title: 'Doc 3',
  correspondent: 11,
  document_type: 21,
  storage_path: 31,
  tags: [41, 42, 43],
  content: 'text content',
  added: new Date('May 4, 2014 03:24:00').toISOString(),
  created: new Date('May 4, 2014 03:24:00').toISOString(),
  modified: new Date('May 4, 2014 03:24:00').toISOString(),
  archive_serial_number: null,
  original_file_name: 'file.pdf',
  owner: null,
  user_can_change: true,
  notes: [
    {
      created: new Date(),
      note: 'note 1',
      user: { id: 1, username: 'user1' },
    },
    {
      created: new Date(),
      note: 'note 2',
      user: { id: 2, username: 'user2' },
    },
  ],
  custom_fields: [
    {
      field: 0,
      document: 3,
      created: new Date(),
      value: 'custom foo bar',
    },
  ],
}

const customFields = [
  {
    id: 0,
    name: 'Field 1',
    data_type: CustomFieldDataType.String,
    created: new Date(),
  },
  {
    id: 1,
    name: 'Custom Field 2',
    data_type: CustomFieldDataType.Integer,
    created: new Date(),
  },
]

describe('DocumentDetailComponent', () => {
  let component: DocumentDetailComponent
  let fixture: ComponentFixture<DocumentDetailComponent>
  let router: Router
  let activatedRoute: ActivatedRoute
  let documentService: DocumentService
  let openDocumentsService: OpenDocumentsService
  let modalService: NgbModal
  let toastService: ToastService
  let documentListViewService: DocumentListViewService
  let settingsService: SettingsService
  let customFieldsService: CustomFieldsService
  let deviceDetectorService: DeviceDetectorService
  let httpTestingController: HttpTestingController
  let componentRouterService: ComponentRouterService

  let currentUserCan = true
  let currentUserHasObjectPermissions = true
  let currentUserOwnsObject = true

  beforeEach(async () => {
    TestBed.configureTestingModule({
      imports: [
        DocumentDetailComponent,
        RouterModule.forRoot(routes),
        NgxBootstrapIconsModule.pick(allIcons),
      ],
      providers: [
        DocumentTitlePipe,
        {
          provide: TagService,
          useValue: {
            getCachedMany: (ids: number[]) =>
              of(
                ids.map((id) => ({
                  id,
                  name: `Tag${id}`,
                  is_inbox_tag: true,
                  color: '#ff0000',
                  text_color: '#000000',
                }))
              ),
            listAll: () =>
              of({
                count: 3,
                all: [41, 42, 43],
                results: [
                  {
                    id: 41,
                    name: 'Tag41',
                    is_inbox_tag: true,
                    color: '#ff0000',
                    text_color: '#000000',
                  },
                  {
                    id: 42,
                    name: 'Tag42',
                    is_inbox_tag: true,
                    color: '#ff0000',
                    text_color: '#000000',
                  },
                  {
                    id: 43,
                    name: 'Tag43',
                    is_inbox_tag: true,
                    color: '#ff0000',
                    text_color: '#000000',
                  },
                ],
              }),
          },
        },
        {
          provide: CorrespondentService,
          useValue: {
            listAll: () =>
              of({
                results: [
                  {
                    id: 11,
                    name: 'Correspondent11',
                  },
                ],
              }),
          },
        },
        {
          provide: DocumentTypeService,
          useValue: {
            listAll: () =>
              of({
                results: [
                  {
                    id: 21,
                    name: 'DocumentType21',
                  },
                ],
              }),
          },
        },
        {
          provide: StoragePathService,
          useValue: {
            listAll: () =>
              of({
                results: [
                  {
                    id: 31,
                    name: 'StoragePath31',
                  },
                ],
              }),
          },
        },
        {
          provide: UserService,
          useValue: {
            listAll: () =>
              of({
                results: [
                  {
                    id: 1,
                    username: 'user1',
                  },
                  {
                    id: 2,
                    username: 'user2',
                  },
                ],
              }),
          },
        },
        CustomFieldsService,
        {
          provide: PermissionsService,
          useValue: {
            currentUserCan: () => currentUserCan,
            currentUserHasObjectPermissions: () =>
              currentUserHasObjectPermissions,
            currentUserOwnsObject: () => currentUserOwnsObject,
          },
        },
        PermissionsGuard,
        CustomDatePipe,
        DatePipe,
        provideHttpClient(withInterceptorsFromDi()),
        provideHttpClientTesting(),
      ],
    }).compileComponents()

    router = TestBed.inject(Router)
    jest.spyOn(router, 'navigate').mockResolvedValue(true)
    activatedRoute = TestBed.inject(ActivatedRoute)
    openDocumentsService = TestBed.inject(OpenDocumentsService)
    documentService = TestBed.inject(DocumentService)
    modalService = TestBed.inject(NgbModal)
    toastService = TestBed.inject(ToastService)
    documentListViewService = TestBed.inject(DocumentListViewService)
    settingsService = TestBed.inject(SettingsService)
    settingsService.currentUser.set({ id: 1 })
    customFieldsService = TestBed.inject(CustomFieldsService)
    deviceDetectorService = TestBed.inject(DeviceDetectorService)
    fixture = TestBed.createComponent(DocumentDetailComponent)
    httpTestingController = TestBed.inject(HttpTestingController)
    componentRouterService = TestBed.inject(ComponentRouterService)
    component = fixture.componentInstance
  })

  afterEach(() => {
    jest.useRealTimers()
  })

  function initNormally() {
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
    jest
      .spyOn(documentService, 'get')
      .mockReturnValueOnce(of(Object.assign({}, doc)))
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
    jest
      .spyOn(openDocumentsService, 'openDocument')
      .mockReturnValueOnce(of(true))
    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
      of({
        count: customFields.length,
        all: customFields.map((f) => f.id),
        results: customFields,
      })
    )
    fixture.detectChanges()
  }

  it('should load four tabs via url params', () => {
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 3, section: 'notes' })))
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
    jest
      .spyOn(openDocumentsService, 'openDocument')
      .mockReturnValueOnce(of(true))
    fixture.detectChanges()
    expect(component.activeNavID()).toEqual(
      component.DocumentDetailNavIDs.Notes
    )
  })

  it('should switch from preview to details when pdf preview enters the DOM', () => {
    jest.useFakeTimers()
    component.nav = {
      activeId: component.DocumentDetailNavIDs.Preview,
      select: jest.fn(),
    } as any
    ;(component as any).pdfPreview = {
      nativeElement: { offsetParent: {} },
    }

    jest.advanceTimersByTime(0)
    expect(component.nav.select).toHaveBeenCalledWith(
      component.DocumentDetailNavIDs.Details
    )
  })

  it('should forward title key up value to titleSubject', () => {
    const subjectSpy = jest.spyOn(component.titleSubject, 'next')

    component.titleKeyUp({ target: { value: 'Updated title' } })

    expect(subjectSpy).toHaveBeenCalledWith('Updated title')
  })

  it('should change url on tab switch', () => {
    initNormally()
    const navigateSpy = jest.spyOn(router, 'navigate')
    component.nav.select(component.DocumentDetailNavIDs.Notes)
    component.nav.navChange.next({
      activeId: 1,
      nextId: component.DocumentDetailNavIDs.Notes,
      preventDefault: () => {},
    })
    fixture.detectChanges()
    expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'notes'])
  })

  it('should forward id without section to details', () => {
    const navigateSpy = jest.spyOn(router, 'navigate')
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 3 })))
    fixture.detectChanges()
    expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'details'], {
      replaceUrl: true,
    })
  })

  it('should update title after debounce', () => {
    jest.useFakeTimers()
    initNormally()
    component.titleInput.value = 'Foo Bar'
    component.titleSubject.next('Foo Bar')
    jest.advanceTimersByTime(1000)
    expect(component.documentForm.get('title').value).toEqual('Foo Bar')
  })

  it('should update title before doc change if was not updated via debounce', () => {
    jest.useFakeTimers()
    initNormally()
    component.titleInput.value = 'Foo Bar'
    component.titleInput.inputField.nativeElement.dispatchEvent(
      new Event('change')
    )
    jest.advanceTimersByTime(1000)
    expect(component.documentForm.get('title').value).toEqual('Foo Bar')
  })

  it('should load non-open document via param', () => {
    initNormally()
    expect(component.document()).toEqual(doc)
  })

  it('should redirect to root when opening a version document id', () => {
    const navigateSpy = jest.spyOn(router, 'navigate')
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 10, section: 'details' })))
    jest
      .spyOn(documentService, 'get')
      .mockReturnValueOnce(throwError(() => ({ status: 404 }) as any))
    const getRootSpy = jest
      .spyOn(documentService, 'getRootId')
      .mockReturnValue(of({ root_id: 3 }))
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
    jest
      .spyOn(openDocumentsService, 'openDocument')
      .mockReturnValueOnce(of(true))
    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
      of({
        count: customFields.length,
        all: customFields.map((f) => f.id),
        results: customFields,
      })
    )

    fixture.detectChanges()
    httpTestingController.expectOne(component.previewUrl()).flush('preview')

    expect(getRootSpy).toHaveBeenCalledWith(10)
    expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'details'], {
      replaceUrl: true,
    })
  })

  it('should navigate to 404 when root lookup fails', () => {
    const navigateSpy = jest.spyOn(router, 'navigate')
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 10, section: 'details' })))
    jest
      .spyOn(documentService, 'get')
      .mockReturnValueOnce(throwError(() => ({ status: 404 }) as any))
    jest
      .spyOn(documentService, 'getRootId')
      .mockReturnValue(throwError(() => new Error('boom')))
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
    jest
      .spyOn(openDocumentsService, 'openDocument')
      .mockReturnValueOnce(of(true))
    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
      of({
        count: customFields.length,
        all: customFields.map((f) => f.id),
        results: customFields,
      })
    )

    fixture.detectChanges()
    httpTestingController.expectOne(component.previewUrl()).flush('preview')

    expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
  })

  it('should not render a delete button for the root/original version', () => {
    const docWithVersions = {
      ...doc,
      versions: [
        {
          id: 10,
          added: new Date('2024-01-02T00:00:00Z'),
          version_label: 'Edited',
          checksum: 'bbbb',
          is_root: false,
        },
        {
          id: doc.id,
          added: new Date('2024-01-01T00:00:00Z'),
          version_label: 'Original',
          checksum: 'aaaa',
          is_root: true,
        },
      ],
    } as Document

    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(docWithVersions))
    jest
      .spyOn(documentService, 'getMetadata')
      .mockReturnValue(of({ has_archive_version: true } as any))
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
    jest
      .spyOn(openDocumentsService, 'openDocument')
      .mockReturnValueOnce(of(true))
    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
      of({
        count: customFields.length,
        all: customFields.map((f) => f.id),
        results: customFields,
      })
    )

    fixture.detectChanges()
    httpTestingController.expectOne(component.previewUrl()).flush('preview')
    fixture.detectChanges()

    const deleteButtons = fixture.debugElement.queryAll(
      By.css('pngx-confirm-button')
    )
    expect(deleteButtons).toHaveLength(1)
  })

  it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
    initNormally()
    component.activeNavID.set(component.DocumentDetailNavIDs.Duplicates)
    const noDupDoc = { ...doc, duplicate_documents: [] }

    component.updateComponent(noDupDoc)

    expect(component.activeNavID()).toEqual(
      component.DocumentDetailNavIDs.Details
    )
  })

  it('should load already-opened document via param', () => {
    initNormally()
    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(doc)
    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
      of({
        count: customFields.length,
        all: customFields.map((f) => f.id),
        results: customFields,
      })
    )
    fixture.detectChanges() // calls ngOnInit
    expect(component.document()).toEqual(doc)
  })

  it('should update cached open document duplicates when reloading an open doc', () => {
    const openDoc = { ...doc, duplicate_documents: [{ id: 1, title: 'Old' }] }
    const updatedDuplicates = [
      { id: 2, title: 'Newer duplicate', deleted_at: null },
    ]
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
    jest.spyOn(documentService, 'get').mockReturnValue(
      of({
        ...doc,
        modified: '2024-01-02T00:00:00Z',
        duplicate_documents: updatedDuplicates,
      })
    )
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
    const saveSpy = jest.spyOn(openDocumentsService, 'save')
    jest.spyOn(openDocumentsService, 'openDocument').mockReturnValue(of(true))
    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
      of({
        count: customFields.length,
        all: customFields.map((f) => f.id),
        results: customFields,
      })
    )

    fixture.detectChanges()

    expect(openDoc.duplicate_documents).toEqual(updatedDuplicates)
    expect(saveSpy).toHaveBeenCalled()
  })

  it('should disable form if user cannot edit', () => {
    currentUserHasObjectPermissions = false
    initNormally()
    expect(component.documentForm.disabled).toBeTruthy()
  })

  it('should not attempt to retrieve objects if user does not have permissions', () => {
    currentUserCan = false
    initNormally()
    expect(component.correspondents()).toBeUndefined()
    expect(component.documentTypes()).toBeUndefined()
    expect(component.storagePaths()).toBeUndefined()
    expect(component.users()).toBeUndefined()
    httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
    httpTestingController.expectNone(
      `${environment.apiBaseUrl}documents/correspondents/`
    )
    httpTestingController.expectNone(
      `${environment.apiBaseUrl}documents/document_types/`
    )
    httpTestingController.expectNone(
      `${environment.apiBaseUrl}documents/storage_paths/`
    )
    currentUserCan = true
  })

  it('should support creating tag, remove from suggestions', () => {
    initNormally()
    component.suggestions.set({
      suggested_tags: ['Tag1', 'NewTag12'],
    })
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    const modalSpy = jest.spyOn(modalService, 'open')
    component.createTag('NewTag12')
    expect(modalSpy).toHaveBeenCalled()
    openModal.componentInstance.succeeded.next({
      id: 12,
      name: 'NewTag12',
      is_inbox_tag: true,
      color: '#ff0000',
      text_color: '#000000',
    })
    expect(component.tagsInput.value).toContain(12)
    expect(component.suggestions().suggested_tags).not.toContain('NewTag12')
  })

  it('should support creating document type, remove from suggestions', () => {
    initNormally()
    component.suggestions.set({
      suggested_document_types: ['DocumentType1', 'NewDocType2'],
    })
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    const modalSpy = jest.spyOn(modalService, 'open')
    component.createDocumentType('NewDocType2')
    expect(modalSpy).toHaveBeenCalled()
    openModal.componentInstance.succeeded.next({ id: 12, name: 'NewDocType12' })
    expect(component.documentForm.get('document_type').value).toEqual(12)
    expect(component.suggestions().suggested_document_types).not.toContain(
      'NewDocType2'
    )
  })

  it('should support creating correspondent, remove from suggestions', () => {
    initNormally()
    component.suggestions.set({
      suggested_correspondents: ['Correspondent1', 'NewCorrrespondent12'],
    })
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    const modalSpy = jest.spyOn(modalService, 'open')
    component.createCorrespondent('NewCorrrespondent12')
    expect(modalSpy).toHaveBeenCalled()
    openModal.componentInstance.succeeded.next({
      id: 12,
      name: 'NewCorrrespondent12',
    })
    expect(component.documentForm.get('correspondent').value).toEqual(12)
    expect(component.suggestions().suggested_correspondents).not.toContain(
      'NewCorrrespondent12'
    )
  })

  it('should support creating storage path', () => {
    initNormally()
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    const modalSpy = jest.spyOn(modalService, 'open')
    component.createStoragePath('NewStoragePath12')
    expect(modalSpy).toHaveBeenCalled()
    openModal.componentInstance.succeeded.next({
      id: 12,
      name: 'NewStoragePath12',
    })
    expect(component.documentForm.get('storage_path').value).toEqual(12)
  })

  it('should allow dischard changes', () => {
    initNormally()
    component.title.set('Foo Bar')
    fixture.detectChanges()
    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
    component.discard()
    fixture.detectChanges()
    expect(component.title()).toEqual(doc.title)
    expect(openDocumentsService.hasDirty()).toBeFalsy()
    // this time with error, mostly for coverage
    component.title.set('Foo Bar')
    fixture.detectChanges()
    const navigateSpy = jest.spyOn(router, 'navigate')
    jest
      .spyOn(documentService, 'get')
      .mockReturnValueOnce(throwError(() => new Error('unable to discard')))
    component.discard()
    fixture.detectChanges()
    expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
  })

  it('discard should request the currently selected version', () => {
    initNormally()
    const getSpy = jest.spyOn(documentService, 'get')
    getSpy.mockClear()
    getSpy.mockReturnValueOnce(of(doc))

    component.selectedVersionId.set(10)
    component.discard()

    expect(getSpy).toHaveBeenCalledWith(component.documentId(), 10)
  })

  it('should 404 on invalid id', () => {
    const navigateSpy = jest.spyOn(router, 'navigate')
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 999, section: 'details' })))
    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(null))
    fixture.detectChanges()
    expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
  })

  it('should navigate to 404 if error on load', () => {
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
    const navigateSpy = jest.spyOn(router, 'navigate')
    jest
      .spyOn(documentService, 'get')
      .mockReturnValue(throwError(() => new Error('not found')))
    fixture.detectChanges()
    expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
  })

  it('should support save, close and show success toast', () => {
    initNormally()
    component.title.set('Foo Bar')
    const closeSpy = jest.spyOn(component, 'close')
    const patchSpy = jest.spyOn(documentService, 'patch')
    const toastSpy = jest.spyOn(toastService, 'showInfo')
    patchSpy.mockImplementation((o) => of(doc))
    component.save(true)
    expect(patchSpy).toHaveBeenCalled()
    expect(closeSpy).toHaveBeenCalled()
    expect(toastSpy).toHaveBeenCalledWith(
      'Document "Doc 3" saved successfully.'
    )
  })

  it('should support save without close and show success toast', () => {
    initNormally()
    component.title.set('Foo Bar')
    const closeSpy = jest.spyOn(component, 'close')
    const patchSpy = jest.spyOn(documentService, 'patch')
    const toastSpy = jest.spyOn(toastService, 'showInfo')
    patchSpy.mockImplementation((o) => of(doc))
    component.save()
    expect(patchSpy).toHaveBeenCalled()
    expect(closeSpy).not.toHaveBeenCalled()
    expect(toastSpy).toHaveBeenCalledWith(
      'Document "Doc 3" saved successfully.'
    )
  })

  it('save should target currently selected version', () => {
    initNormally()
    component.selectedVersionId.set(10)
    const patchSpy = jest.spyOn(documentService, 'patch')
    patchSpy.mockReturnValue(of(doc))

    component.save()

    expect(patchSpy).toHaveBeenCalled()
    expect(patchSpy.mock.calls[0][1]).toEqual(10)
  })

  it('should show toast error on save if error occurs', () => {
    currentUserHasObjectPermissions = true
    initNormally()
    component.title.set('Foo Bar')
    const closeSpy = jest.spyOn(component, 'close')
    const patchSpy = jest.spyOn(documentService, 'patch')
    const toastSpy = jest.spyOn(toastService, 'showError')
    const error = new Error('failed to save')
    patchSpy.mockImplementation(() => throwError(() => error))
    component.save()
    expect(patchSpy).toHaveBeenCalled()
    expect(closeSpy).not.toHaveBeenCalled()
    expect(toastSpy).toHaveBeenCalledWith(
      'Error saving document "Doc 3"',
      error
    )
  })

  it('should show error toast on save but close if user can no longer edit', () => {
    currentUserHasObjectPermissions = false
    initNormally()
    component.title.set('Foo Bar')
    const closeSpy = jest.spyOn(component, 'close')
    const patchSpy = jest.spyOn(documentService, 'patch')
    const toastSpy = jest.spyOn(toastService, 'showInfo')
    patchSpy.mockImplementation(() =>
      throwError(() => new Error('failed to save'))
    )
    component.save(true)
    expect(patchSpy).toHaveBeenCalled()
    expect(closeSpy).toHaveBeenCalled()
    expect(toastSpy).toHaveBeenCalledWith(
      'Document "Doc 3" saved successfully.'
    )
  })

  it('should allow save and next', () => {
    initNormally()
    const nextDocId = 100
    component.title.set('Foo Bar')
    const patchSpy = jest.spyOn(documentService, 'patch')
    patchSpy.mockReturnValue(of(doc))
    const nextSpy = jest.spyOn(documentListViewService, 'getNext')
    nextSpy.mockReturnValue(of(nextDocId))
    const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
    closeSpy.mockReturnValue(of(true))
    const navigateSpy = jest.spyOn(router, 'navigate')

    component.saveEditNext()
    expect(patchSpy).toHaveBeenCalled()
    expect(navigateSpy).toHaveBeenCalledWith(['documents', nextDocId])
    expect
  })

  it('should show toast error on save & next if error occurs', () => {
    currentUserHasObjectPermissions = true
    initNormally()
    component.title.set('Foo Bar')
    const closeSpy = jest.spyOn(component, 'close')
    const patchSpy = jest.spyOn(documentService, 'patch')
    const toastSpy = jest.spyOn(toastService, 'showError')
    const error = new Error('failed to save')
    patchSpy.mockImplementation(() => throwError(() => error))
    component.saveEditNext()
    expect(patchSpy).toHaveBeenCalled()
    expect(closeSpy).not.toHaveBeenCalled()
    expect(toastSpy).toHaveBeenCalledWith('Error saving document', error)
  })

  it('should show save button and save & close or save & next', () => {
    const nextSpy = jest.spyOn(component, 'hasNext')
    nextSpy.mockReturnValueOnce(false)
    fixture.detectChanges()
    expect(
      fixture.debugElement
        .queryAll(By.css('button'))
        .find((b) => b.nativeElement.textContent === 'Save')
    ).not.toBeUndefined()
    expect(
      fixture.debugElement
        .queryAll(By.css('button'))
        .find((b) => b.nativeElement.textContent === 'Save & close')
    ).not.toBeUndefined()
    expect(
      fixture.debugElement
        .queryAll(By.css('button'))
        .find((b) => b.nativeElement.textContent === 'Save & next')
    ).toBeUndefined()
    nextSpy.mockReturnValue(true)
    component.networkActive.set(true)
    fixture.detectChanges()
    expect(
      fixture.debugElement
        .queryAll(By.css('button'))
        .find((b) => b.nativeElement.textContent === 'Save & close')
    ).toBeUndefined()
    expect(
      fixture.debugElement
        .queryAll(By.css('button'))
        .find((b) => b.nativeElement.textContent === 'Save & next')
    ).not.toBeUndefined()
  })

  it('should allow close and navigate to documents by default', () => {
    initNormally()
    const navigateSpy = jest.spyOn(router, 'navigate')
    component.close()
    expect(navigateSpy).toHaveBeenCalledWith(['documents'])
  })

  it('should allow close and navigate to the last view if available', () => {
    initNormally()
    jest
      .spyOn(componentRouterService, 'getComponentURLBefore')
      .mockReturnValue('dashboard')
    const navigateSpy = jest.spyOn(router, 'navigate')
    component.close()
    expect(navigateSpy).toHaveBeenCalledWith(['dashboard'])
  })

  it('should allow close and navigate to documents by default', () => {
    initNormally()
    jest
      .spyOn(documentListViewService, 'activeSavedViewId', 'get')
      .mockReturnValue(77)
    const navigateSpy = jest.spyOn(router, 'navigate')
    component.close()
    expect(navigateSpy).toHaveBeenCalledWith(['view', 77])
  })

  it('should not close if e.g. user-cancelled', () => {
    initNormally()
    jest.spyOn(openDocumentsService, 'closeDocument').mockReturnValue(of(false))
    const navigateSpy = jest.spyOn(router, 'navigate')
    component.close()
    expect(navigateSpy).not.toHaveBeenCalled()
  })

  it('should support delete, ask for confirmation', () => {
    initNormally()
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    const modalSpy = jest.spyOn(modalService, 'open')
    const deleteSpy = jest.spyOn(documentService, 'delete')
    deleteSpy.mockReturnValue(of(true))
    component.delete()
    expect(modalSpy).toHaveBeenCalled()
    const modalCloseSpy = jest.spyOn(openModal, 'close')
    openModal.componentInstance.confirmClicked.next()
    expect(deleteSpy).toHaveBeenCalled()
    expect(modalCloseSpy).toHaveBeenCalled()
  })

  it('should allow retry delete if error', () => {
    initNormally()
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    const modalSpy = jest.spyOn(modalService, 'open')
    const deleteSpy = jest.spyOn(documentService, 'delete')
    deleteSpy.mockReturnValueOnce(throwError(() => new Error('one time')))
    component.delete()
    expect(modalSpy).toHaveBeenCalled()
    const modalCloseSpy = jest.spyOn(openModal, 'close')
    openModal.componentInstance.confirmClicked.next()
    expect(deleteSpy).toHaveBeenCalled()
    expect(modalCloseSpy).not.toHaveBeenCalled()
    deleteSpy.mockReturnValueOnce(of(true))
    // retry
    openModal.componentInstance.confirmClicked.next()
    expect(deleteSpy).toHaveBeenCalled()
    expect(modalCloseSpy).toHaveBeenCalled()
  })

  it('should support more like quick filter', () => {
    initNormally()
    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
    component.moreLike()
    expect(qfSpy).toHaveBeenCalledWith([
      {
        rule_type: FILTER_FULLTEXT_MORELIKE,
        value: doc.id.toString(),
      },
    ])
  })

  it('should support reprocess, confirm and close modal after started', () => {
    initNormally()
    const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
    reprocessSpy.mockReturnValue(of(true))
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    const modalSpy = jest.spyOn(modalService, 'open')
    const toastSpy = jest.spyOn(toastService, 'showInfo')
    component.reprocess()
    const modalCloseSpy = jest.spyOn(openModal, 'close')
    openModal.componentInstance.confirmClicked.next()
    expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
    expect(modalSpy).toHaveBeenCalled()
    expect(toastSpy).toHaveBeenCalled()
    expect(modalCloseSpy).toHaveBeenCalled()
  })

  it('should pass remote OCR choice when reprocessing', () => {
    initNormally()
    const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
    reprocessSpy.mockReturnValue(of(true))
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    component.reprocess()
    openModal.componentInstance.remoteOcr = true
    openModal.componentInstance.confirmClicked.next()
    expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, true)
  })

  it('should show error if redo ocr call fails', () => {
    initNormally()
    const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
    const toastSpy = jest.spyOn(toastService, 'showError')
    component.reprocess()
    const modalCloseSpy = jest.spyOn(openModal, 'close')
    reprocessSpy.mockReturnValue(throwError(() => new Error('error occurred')))
    openModal.componentInstance.confirmClicked.next()
    expect(toastSpy).toHaveBeenCalled()
    expect(modalCloseSpy).not.toHaveBeenCalled()
  })

  it('should support next doc', () => {
    initNormally()
    const serviceSpy = jest.spyOn(documentListViewService, 'getNext')
    const routerSpy = jest.spyOn(router, 'navigate')
    serviceSpy.mockReturnValue(of(100))
    component.nextDoc()
    expect(serviceSpy).toHaveBeenCalled()
    expect(routerSpy).toHaveBeenCalledWith(['documents', 100])
  })

  it('should support previous doc', () => {
    initNormally()
    const serviceSpy = jest.spyOn(documentListViewService, 'getPrevious')
    const routerSpy = jest.spyOn(router, 'navigate')
    serviceSpy.mockReturnValue(of(100))
    component.previousDoc()
    expect(serviceSpy).toHaveBeenCalled()
    expect(routerSpy).toHaveBeenCalledWith(['documents', 100])
  })

  it('should support password-protected PDFs with a password field', () => {
    initNormally()
    component.onError({ name: 'PasswordException' }) // normally dispatched by pdf viewer
    expect(component.requiresPassword).toBeTruthy()
    fixture.detectChanges()
    expect(
      fixture.debugElement.query(By.css('input[type=password]'))
    ).not.toBeUndefined()
    component.password = 'foo'
    component.pdfPreviewLoaded({ numPages: 1000 } as any)
    expect(component.requiresPassword).toBeFalsy()
  })

  it('should support Enter key in password field', () => {
    initNormally()
    component.metadata.set({ has_archive_version: true })
    component.onError({ name: 'PasswordException' }) // normally dispatched by pdf viewer
    fixture.detectChanges()
    expect(component.password).toBeUndefined()
    const pwField = fixture.debugElement.query(By.css('input[type=password]'))
    pwField.nativeElement.value = 'foobar'
    pwField.nativeElement.dispatchEvent(
      new KeyboardEvent('keyup', { key: 'Enter' })
    )
    expect(component.password).toEqual('foobar')
  })

  it('should update n pages after pdf loaded', () => {
    initNormally()
    component.pdfPreviewLoaded({ numPages: 1000 } as any)
    expect(component.previewNumPages()).toEqual(1000)
  })

  it('should mark preview loaded after pdf loads', () => {
    initNormally()
    expect(component.previewLoaded()).toBeFalsy()
    component.pdfPreviewLoaded({ numPages: 1000 } as any)
    expect(component.previewNumPages()).toEqual(1000)
    expect(component.previewLoaded()).toBeTruthy()
  })

  it('should support zoom controls', () => {
    initNormally()
    component.setZoom(PdfZoomLevel.One) // from select
    expect(component.previewZoomSetting()).toEqual('1')
    component.increaseZoom()
    expect(component.previewZoomSetting()).toEqual('1.5')
    component.increaseZoom()
    expect(component.previewZoomSetting()).toEqual('2')
    component.decreaseZoom()
    expect(component.previewZoomSetting()).toEqual('1.5')
    component.setZoom(PdfZoomLevel.One) // from select
    component.decreaseZoom()
    expect(component.previewZoomSetting()).toEqual('.75')

    component.setZoom(PdfZoomScale.PageFit) // from select
    expect(component.previewZoomScale()).toEqual('page-fit')
    expect(component.previewZoomSetting()).toEqual('1')
    component.increaseZoom()
    expect(component.previewZoomSetting()).toEqual('1.5')
    expect(component.previewZoomScale()).toEqual('page-width')

    component.setZoom(PdfZoomScale.PageFit) // from select
    expect(component.previewZoomScale()).toEqual('page-fit')
    expect(component.previewZoomSetting()).toEqual('1')
    component.decreaseZoom()
    expect(component.previewZoomSetting()).toEqual('.5')
    expect(component.previewZoomScale()).toEqual('page-width')
  })

  it('should select correct zoom setting in dropdown', () => {
    initNormally()
    component.setZoom(PdfZoomScale.PageFit)
    expect(component.currentZoom).toEqual(PdfZoomScale.PageFit)
    component.setZoom(PdfZoomLevel.Quarter)
    expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter)
  })

  it('should support updating notes dynamically', () => {
    const notes = [
      {
        id: 1,
        note: 'hello world',
      },
    ]
    initNormally()
    const refreshSpy = jest.spyOn(openDocumentsService, 'refreshDocument')
    component.notesUpdated(notes) // called by notes component
    expect(component.document().notes).toEqual(notes)
    expect(refreshSpy).toHaveBeenCalled()
  })

  it('should support quick filtering by correspondent', () => {
    initNormally()
    const object = {
      id: 22,
      name: 'Correspondent22',
    } as Correspondent
    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
    component.filterDocuments([object], DataType.Correspondent)
    expect(qfSpy).toHaveBeenCalledWith([
      {
        rule_type: FILTER_CORRESPONDENT,
        value: object.id.toString(),
      },
    ])
  })

  it('should support quick filtering by doc type', () => {
    initNormally()
    const object = { id: 22, name: 'DocumentType22' } as DocumentType
    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
    component.filterDocuments([object], DataType.DocumentType)
    expect(qfSpy).toHaveBeenCalledWith([
      {
        rule_type: FILTER_DOCUMENT_TYPE,
        value: object.id.toString(),
      },
    ])
  })

  it('should support quick filtering by storage path', () => {
    initNormally()
    const object = {
      id: 22,
      name: 'StoragePath22',
      path: '/foo/bar/',
    } as StoragePath
    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
    component.filterDocuments([object], DataType.StoragePath)
    expect(qfSpy).toHaveBeenCalledWith([
      {
        rule_type: FILTER_STORAGE_PATH,
        value: object.id.toString(),
      },
    ])
  })

  it('should support quick filtering by all tags', () => {
    initNormally()
    const object1 = {
      id: 22,
      name: 'Tag22',
      is_inbox_tag: true,
      color: '#ff0000',
      text_color: '#000000',
    } as Tag
    const object2 = {
      id: 23,
      name: 'Tag22',
      is_inbox_tag: true,
      color: '#ff0000',
      text_color: '#000000',
    } as Tag
    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
    component.filterDocuments([object1, object2], DataType.Tag)
    expect(qfSpy).toHaveBeenCalledWith([
      {
        rule_type: FILTER_HAS_TAGS_ALL,
        value: object1.id.toString(),
      },
      {
        rule_type: FILTER_HAS_TAGS_ALL,
        value: object2.id.toString(),
      },
    ])
  })

  it('should support quick filtering by date after - 1d and before +1d', () => {
    initNormally()
    const object = { year: 2023, month: 5, day: 14 } as NgbDateStruct
    const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
    component.filterDocuments([object])
    expect(qfSpy).toHaveBeenCalledWith([
      {
        rule_type: FILTER_CREATED_AFTER,
        value: '2023-05-13',
      },
      {
        rule_type: FILTER_CREATED_BEFORE,
        value: '2023-05-15',
      },
    ])
  })

  it('should detect RTL languages and add css class to content textarea', () => {
    initNormally()
    component.metadata.set({ lang: 'he' })
    component.nav.select(2) // content
    fixture.detectChanges()
    expect(component.isRTL).toBeTruthy()
    expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
  })

  it('should display built-in pdf viewer if not disabled', async () => {
    initNormally()
    component.document.update((document) => ({
      ...document,
      archived_file_name: 'file.pdf',
    }))
    settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
    expect(component.useNativePdfViewer).toBeFalsy()
    await fixture.whenStable()
    expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
  })

  it('should display native pdf viewer if enabled', () => {
    initNormally()
    component.document.update((document) => ({
      ...document,
      archived_file_name: 'file.pdf',
    }))
    settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
    expect(component.useNativePdfViewer).toBeTruthy()
    fixture.detectChanges()
    expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
  })

  it('should reflect signal-backed document detail display settings', () => {
    settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
    settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
      component.DocumentDetailFieldID.Correspondent,
    ])

    expect(component.showThumbnailOverlay).toBeFalsy()
    expect(
      component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
    ).toBeTruthy()
    expect(
      component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
    ).toBeFalsy()

    settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
    settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])

    expect(component.showThumbnailOverlay).toBeTruthy()
    expect(
      component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
    ).toBeFalsy()
  })

  it('should attempt to retrieve metadata', () => {
    const metadataSpy = jest.spyOn(documentService, 'getMetadata')
    metadataSpy.mockReturnValue(of({ has_archive_version: true }))
    initNormally()
    expect(metadataSpy).toHaveBeenCalledWith(doc.id, null)
  })

  it('should pass metadata version only for non-latest selected versions', () => {
    const metadataSpy = jest.spyOn(documentService, 'getMetadata')
    metadataSpy.mockReturnValue(of({ has_archive_version: true }))
    initNormally()
    httpTestingController.expectOne(component.previewUrl()).flush('preview')

    expect(metadataSpy).toHaveBeenCalledWith(doc.id, null)

    metadataSpy.mockClear()
    component.document().versions = [
      { id: 10, is_root: false },
      { id: doc.id, is_root: true },
    ] as any
    jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
    jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
    jest
      .spyOn(documentService, 'get')
      .mockReturnValue(of({ content: 'root' } as Document))

    component.selectVersion(doc.id)
    httpTestingController.expectOne('preview-root').flush('root')

    expect(metadataSpy).toHaveBeenCalledWith(doc.id, doc.id)
  })

  it('should show an error if failed metadata retrieval', () => {
    const error = new Error('metadata error')
    jest
      .spyOn(documentService, 'getMetadata')
      .mockReturnValue(throwError(() => error))
    const toastSpy = jest.spyOn(toastService, 'showError')
    initNormally()
    expect(toastSpy).toHaveBeenCalledWith('Error retrieving metadata', error)
  })

  it('should display custom fields', () => {
    initNormally()
    expect(fixture.debugElement.nativeElement.textContent).toContain(
      customFields[0].name
    )
  })

  it('should support add custom field, correctly send via post', () => {
    initNormally()
    const initialLength = doc.custom_fields.length
    expect(component.customFieldFormFields).toHaveLength(initialLength)
    component.addField(customFields[1])
    fixture.detectChanges()
    expect(component.document().custom_fields).toHaveLength(initialLength + 1)
    expect(component.customFieldFormFields).toHaveLength(initialLength + 1)
    expect(fixture.debugElement.nativeElement.textContent).toContain(
      customFields[1].name
    )
    const patchSpy = jest.spyOn(documentService, 'patch')
    component.save(true)
    expect(patchSpy.mock.lastCall[0].custom_fields).toHaveLength(2)
    expect(patchSpy.mock.lastCall[0].custom_fields[1]).toEqual({
      field: customFields[1].id,
      value: null,
    })
  })

  it('should support remove custom field, correctly send via post', () => {
    initNormally()
    const initialLength = doc.custom_fields.length
    expect(component.customFieldFormFields).toHaveLength(initialLength)
    component.removeField(doc.custom_fields[0])
    fixture.detectChanges()
    expect(component.document().custom_fields).toHaveLength(initialLength - 1)
    expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
    expect(
      fixture.debugElement.query(By.css('form ul')).nativeElement.textContent
    ).not.toContain('Field 1')
    const patchSpy = jest.spyOn(documentService, 'patch')
    component.save(true)
    expect(patchSpy.mock.lastCall[0].custom_fields).toHaveLength(
      initialLength - 1
    )
  })

  it('should correctly determine changed fields', () => {
    initNormally()
    expect(component['getChangedFields']()).toEqual({
      id: doc.id,
    })
    component.documentForm.get('title').setValue('Foo Bar')
    component.documentForm.get('permissions_form').setValue({
      owner: 1,
      set_permissions: {
        view: {
          users: [2],
          groups: [],
        },
        change: {
          users: [3],
          groups: [],
        },
      },
    })
    component.documentForm.get('title').markAsDirty()
    component.documentForm.get('permissions_form').markAsDirty()
    expect(component['getChangedFields']()).toEqual({
      id: doc.id,
      title: 'Foo Bar',
      owner: 1,
      set_permissions: {
        view: {
          users: [2],
          groups: [],
        },
        change: {
          users: [3],
          groups: [],
        },
      },
    })
  })

  it('should restore changed fields and mark as dirty', () => {
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
    const docWithChanges = Object.assign({}, doc)
    docWithChanges.__changedFields = ['title', 'tags', 'owner']
    jest
      .spyOn(openDocumentsService, 'getOpenDocument')
      .mockReturnValue(docWithChanges)
    fixture.detectChanges() // calls ngOnInit
    expect(component.documentForm.get('title').dirty).toBeTruthy()
    expect(component.documentForm.get('tags').dirty).toBeTruthy()
    expect(component.documentForm.get('permissions_form').dirty).toBeTruthy()
  })

  it('should show custom field errors', () => {
    initNormally()
    component.error.set({
      custom_fields: [
        {},
        {},
        { value: ['This field may not be null.'] },
        {},
        { non_field_errors: ['Enter a valid URL.'] },
      ],
    })
    expect(component.getCustomFieldError(2)).toEqual([
      'This field may not be null.',
    ])
    expect(component.getCustomFieldError(4)).toEqual(['Enter a valid URL.'])
  })

  it('should refresh custom fields when created', () => {
    initNormally()
    const refreshSpy = jest.spyOn(component, 'refreshCustomFields')
    fixture.debugElement
      .query(By.directive(CustomFieldsDropdownComponent))
      .triggerEventHandler('created')
    expect(refreshSpy).toHaveBeenCalled()
  })

  it('should get suggestions', () => {
    const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
    const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions')
    suggestionsSpy.mockReturnValue(
      of({
        tags: [42, 43],
        suggested_tags: [],
        suggested_document_types: [],
        suggested_correspondents: [],
      })
    )
    initNormally()
    expect(suggestionsSpy).toHaveBeenCalled()
    expect(aiSuggestionsSpy).not.toHaveBeenCalled()
    expect(component.suggestions()).toEqual({
      tags: [42, 43],
      suggested_tags: [],
      suggested_document_types: [],
      suggested_correspondents: [],
    })
  })

  it('should get AI suggestions when AI is enabled', () => {
    const getSetting = settingsService.get.bind(settingsService)
    jest
      .spyOn(settingsService, 'get')
      .mockImplementation((key) =>
        key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
      )
    const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
    const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions')
    aiSuggestionsSpy.mockReturnValue(
      of({
        tags: [42, 43],
        suggested_tags: [],
        suggested_document_types: [],
        suggested_correspondents: [],
      })
    )
    initNormally()
    expect(suggestionsSpy).not.toHaveBeenCalled()
    expect(aiSuggestionsSpy).toHaveBeenCalled()
    expect(component.suggestions()).toEqual({
      tags: [42, 43],
      suggested_tags: [],
      suggested_document_types: [],
      suggested_correspondents: [],
    })
  })

  it('should reset the suggestions loading state if the document changes mid-request', () => {
    const getSetting = settingsService.get.bind(settingsService)
    jest
      .spyOn(settingsService, 'get')
      .mockImplementation((key) =>
        key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
      )
    const pending = new Subject<any>()
    jest
      .spyOn(documentService, 'getAiSuggestions')
      .mockReturnValue(pending.asObservable())
    initNormally()
    expect(component.suggestionsLoading()).toBeTruthy()

    // the in-flight request is cancelled, e.g. by a websocket-driven reload
    component.docChangeNotifier.next(component.documentId())

    expect(component.suggestionsLoading()).toBeFalsy()
  })

  it('should show error if needed for get suggestions', () => {
    const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
    const errorSpy = jest.spyOn(toastService, 'showError')
    suggestionsSpy.mockImplementationOnce(() =>
      throwError(() => new Error('failed to get suggestions'))
    )
    initNormally()
    expect(suggestionsSpy).toHaveBeenCalled()
    expect(errorSpy).toHaveBeenCalled()
  })

  it('should show incoming update modal when open local draft is older than backend on init', () => {
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
    const modalSpy = jest.spyOn(modalService, 'open')
    const openDoc = Object.assign({}, doc, {
      __changedFields: ['title'],
    })
    // simulate a document being modified elsewhere and db updated
    const remoteDoc = Object.assign({}, doc, {
      modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
    })
    jest
      .spyOn(activatedRoute, 'paramMap', 'get')
      .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
    jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc))
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
    jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
      of({
        count: customFields.length,
        all: customFields.map((f) => f.id),
        results: customFields,
      })
    )
    fixture.detectChanges() // calls ngOnInit
    expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, {
      backdrop: 'static',
    })
    const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
    expect(confirmDialog.messageBold).toContain('Document was updated at')
  })

  it('should react to websocket document updated notifications', () => {
    initNormally()
    const updateMessage = {
      document_id: component.documentId(),
      modified: '2026-02-17T00:00:00Z',
      owner_id: 1,
    }
    const handleSpy = jest
      .spyOn(component as any, 'handleIncomingDocumentUpdated')
      .mockImplementation(() => {})
    const websocketStatusService = TestBed.inject(WebsocketStatusService)

    websocketStatusService.handleDocumentUpdated(updateMessage)

    expect(handleSpy).toHaveBeenCalledWith(updateMessage)
  })

  it('should queue incoming update while network is active and flush after', () => {
    initNormally()
    const loadSpy = jest.spyOn(component as any, 'loadDocument')
    const toastSpy = jest.spyOn(toastService, 'showInfo')

    component.networkActive.set(true)
    ;(component as any).handleIncomingDocumentUpdated({
      document_id: component.documentId(),
      modified: '2026-02-17T00:00:00Z',
    })

    expect(loadSpy).not.toHaveBeenCalled()

    component.networkActive.set(false)
    ;(component as any).flushPendingIncomingUpdate()

    expect(loadSpy).toHaveBeenCalledWith(component.documentId(), true)
    expect(toastSpy).toHaveBeenCalledWith(
      'Document reloaded with latest changes.'
    )
  })

  it('should ignore queued incoming update matching local save modified', () => {
    initNormally()
    const loadSpy = jest.spyOn(component as any, 'loadDocument')
    const toastSpy = jest.spyOn(toastService, 'showInfo')

    component.networkActive.set(true)
    ;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00'
    ;(component as any).handleIncomingDocumentUpdated({
      document_id: component.documentId(),
      modified: '2026-02-17T00:00:00+00:00',
    })

    component.networkActive.set(false)
    ;(component as any).flushPendingIncomingUpdate()

    expect(loadSpy).not.toHaveBeenCalled()
    expect(toastSpy).not.toHaveBeenCalled()
  })

  it('should clear pdf source if preview URL is empty', () => {
    component.pdfSource.set('/preview')
    component.pdfPassword.set('secret')
    component.previewUrl.set(null)
    ;(component as any).updatePdfSource()

    expect(component.pdfSource()).toBeNull()
    expect(component.pdfPassword()).toBeUndefined()
  })

  it('should close incoming update modal if one is open', () => {
    const modalRef = { close: jest.fn() } as unknown as NgbModalRef
    ;(component as any).incomingUpdateModal = modalRef
    ;(component as any).closeIncomingUpdateModal()

    expect(modalRef.close).toHaveBeenCalled()
    expect((component as any).incomingUpdateModal).toBeNull()
  })

  it('should reload remote version when incoming update modal is confirmed', async () => {
    let openModal: NgbModalRef
    modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
    const reloadSpy = jest
      .spyOn(component as any, 'reloadRemoteVersion')
      .mockImplementation(() => {})

    ;(component as any).showIncomingUpdateModal('2026-02-17T00:00:00Z')

    const dialog = openModal.componentInstance as ConfirmDialogComponent
    dialog.confirmClicked.next()
    await openModal.result

    expect(dialog.buttonsEnabled()).toBe(false)
    expect(reloadSpy).toHaveBeenCalled()
    expect((component as any).incomingUpdateModal).toBeNull()
  })

  it('should overwrite open document state when loading remote version with force', () => {
    const openDoc = Object.assign({}, doc, {
      title: 'Locally edited title',
      __changedFields: ['title'],
    })
    const remoteDoc = Object.assign({}, doc, {
      title: 'Remote title',
      modified: '2026-02-17T00:00:00Z',
    })
    jest.spyOn(documentService, 'get').mockReturnValue(of(remoteDoc))
    jest.spyOn(documentService, 'getMetadata').mockReturnValue(
      of({
        has_archive_version: false,
        original_mime_type: 'application/pdf',
      })
    )
    jest.spyOn(documentService, 'getSuggestions').mockReturnValue(
      of({
        suggested_tags: [],
        suggested_document_types: [],
        suggested_correspondents: [],
      })
    )
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
    const setDirtySpy = jest.spyOn(openDocumentsService, 'setDirty')
    const saveSpy = jest.spyOn(openDocumentsService, 'save')

    ;(component as any).loadDocument(doc.id, true)

    expect(openDoc.title).toEqual('Remote title')
    expect(openDoc.__changedFields).toEqual([])
    expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
    expect(saveSpy).toHaveBeenCalled()
    expect(component.previewRevision()).toBe(1)
  })

  it('should ignore incoming update for a different document id', () => {
    initNormally()
    const loadSpy = jest.spyOn(component as any, 'loadDocument')

    ;(component as any).handleIncomingDocumentUpdated({
      document_id: component.documentId() + 1,
      modified: '2026-02-17T00:00:00Z',
    })

    expect(loadSpy).not.toHaveBeenCalled()
  })

  it('should show incoming update modal when local document has unsaved edits', () => {
    initNormally()
    jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
    const modalSpy = jest
      .spyOn(component as any, 'showIncomingUpdateModal')
      .mockImplementation(() => {})

    ;(component as any).handleIncomingDocumentUpdated({
      document_id: component.documentId(),
      modified: '2026-02-17T00:00:00Z',
    })

    expect(modalSpy).toHaveBeenCalledWith('2026-02-17T00:00:00Z')
  })

  it('should reload current document and show toast when reloading remote version', () => {
    component.documentId.set(doc.id)
    const closeModalSpy = jest
      .spyOn(component as any, 'closeIncomingUpdateModal')
      .mockImplementation(() => {})
    const loadSpy = jest
      .spyOn(component as any, 'loadDocument')
      .mockImplementation(() => {})
    const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
    const toastSpy = jest.spyOn(toastService, 'showInfo')

    ;(component as any).reloadRemoteVersion()

    expect(closeModalSpy).toHaveBeenCalled()
    expect(notifySpy).toHaveBeenCalledWith(doc.id)
    expect(loadSpy).toHaveBeenCalledWith(doc.id, true)
    expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
  })

  it('should change preview element by render type', () => {
    initNormally()
    component.document.update((document) => ({
      ...document,
      archived_file_name: 'file.pdf',
    }))
    fixture.detectChanges()
    expect(component.archiveContentRenderType).toEqual(
      component.ContentRenderType.PDF
    )
    expect(
      fixture.debugElement.query(By.css('pdf-viewer-container'))
    ).not.toBeUndefined()

    component.document.update((document) => ({
      ...document,
      archived_file_name: undefined,
      mime_type: 'text/plain',
    }))
    fixture.detectChanges()
    expect(component.archiveContentRenderType).toEqual(
      component.ContentRenderType.Text
    )
    expect(
      fixture.debugElement.query(By.css('div.preview-sticky'))
    ).not.toBeUndefined()

    component.document.update((document) => ({
      ...document,
      mime_type: 'image/jpeg',
    }))
    fixture.detectChanges()
    expect(component.archiveContentRenderType).toEqual(
      component.ContentRenderType.Image
    )
    expect(
      fixture.debugElement.query(By.css('.preview-sticky img'))
    ).not.toBeUndefined()
    component.document.update((document) => ({
      ...document,
      mime_type:
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    }))
    fixture.detectChanges()
    expect(component.archiveContentRenderType).toEqual(
      component.ContentRenderType.Other
    )
    expect(
      fixture.debugElement.query(By.css('object.preview-sticky'))
    ).not.toBeUndefined()
  })

  it('should support pdf editor, handle error', () => {
    let modal: NgbModalRef
    modalService.activeInstances.subscribe((m) => (modal = m[0]))
    const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
    const errorSpy = jest.spyOn(toastService, 'showError')
    initNormally()
    component.selectedVersionId.set(10)
    component.editPdf()
    expect(modal).not.toBeUndefined()
    modal.componentInstance.documentID.set(doc.id)
    expect(modal.componentInstance.versionID()).toBe(10)
    modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
    modal.componentInstance.confirm()
    let req = httpTestingController.expectOne(
      `${environment.apiBaseUrl}documents/edit_pdf/`
    )
    expect(req.request.body).toEqual({
      documents: [10],
      operations: [{ page: 1, rotate: 0, doc: 0 }],
      delete_original: false,
      update_document: false,
      include_metadata: true,
      source_mode: 'explicit_selection',
    })
    req.error(new ErrorEvent('failed'))
    expect(errorSpy).toHaveBeenCalled()

    component.editPdf()
    modal.componentInstance.documentID.set(doc.id)
    modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }]
    modal.componentInstance.deleteOriginal = true
    modal.componentInstance.confirm()
    req = httpTestingController.expectOne(
      `${environment.apiBaseUrl}documents/edit_pdf/`
    )
    req.flush(true)
    expect(closeSpy).toHaveBeenCalled()
  })

  it('should support removing password protection from pdfs', () => {
    let modal: NgbModalRef
    modalService.activeInstances.subscribe((m) => (modal = m[0]))
    initNormally()
    component.selectedVersionId.set(10)
    component.password = 'secret'
    component.removePassword()
    const dialog =
      modal.componentInstance as PasswordRemovalConfirmDialogComponent
    dialog.updateDocument = false
    dialog.includeMetadata = false
    dialog.deleteOriginal = true
    dialog.confirm()
    const req = httpTestingController.expectOne(
      `${environment.apiBaseUrl}documents/remove_password/`
    )
    expect(req.request.body).toEqual({
      documents: [10],
      password: 'secret',
      update_document: false,
      include_metadata: false,
      delete_original: true,
      source_mode: 'explicit_selection',
    })
    req.flush(true)
  })

  it('should require the current password before removing it', () => {
    initNormally()
    const errorSpy = jest.spyOn(toastService, 'showError')
    component.requiresPassword = true
    component.password = ''

    component.removePassword()

    expect(errorSpy).toHaveBeenCalled()
    httpTestingController.expectNone(
      `${environment.apiBaseUrl}documents/remove_password/`
    )
  })

  it('should handle failures when removing password protection', () => {
    let modal: NgbModalRef
    modalService.activeInstances.subscribe((m) => (modal = m[0]))
    initNormally()
    const errorSpy = jest.spyOn(toastService, 'showError')
    component.password = 'secret'

    component.removePassword()
    const dialog =
      modal.componentInstance as PasswordRemovalConfirmDialogComponent
    dialog.confirm()
    const req = httpTestingController.expectOne(
      `${environment.apiBaseUrl}documents/remove_password/`
    )
    req.error(new ErrorEvent('failed'))

    expect(errorSpy).toHaveBeenCalled()
    expect(component.networkActive()).toBe(false)
    expect(dialog.buttonsEnabled()).toBe(true)
  })

  it('should refresh the document when removing password in update mode', () => {
    let modal: NgbModalRef
    modalService.activeInstances.subscribe((m) => (modal = m[0]))
    const refreshSpy = jest.spyOn(openDocumentsService, 'refreshDocument')
    initNormally()
    component.password = 'secret'

    component.removePassword()
    const dialog =
      modal.componentInstance as PasswordRemovalConfirmDialogComponent
    dialog.confirm()
    const req = httpTestingController.expectOne(
      `${environment.apiBaseUrl}documents/remove_password/`
    )
    req.flush(true)

    expect(refreshSpy).toHaveBeenCalledWith(doc.id)
  })

  it('should support keyboard shortcuts', () => {
    initNormally()

    const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true)
    const nextSpy = jest.spyOn(component, 'nextDoc')
    document.dispatchEvent(
      new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
    )
    expect(nextSpy).toHaveBeenCalled()

    jest.spyOn(component, 'hasPrevious').mockReturnValue(true)
    const prevSpy = jest.spyOn(component, 'previousDoc')
    document.dispatchEvent(
      new KeyboardEvent('keydown', { key: 'arrowleft', ctrlKey: true })
    )
    expect(prevSpy).toHaveBeenCalled()

    const isDirtySpy = jest
      .spyOn(openDocumentsService, 'isDirty')
      .mockReturnValue(true)
    const saveSpy = jest.spyOn(component, 'save')
    document.dispatchEvent(
      new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
    )
    expect(saveSpy).toHaveBeenCalled()

    hasNextSpy.mockReturnValue(true)
    const saveNextSpy = jest.spyOn(component, 'saveEditNext')
    document.dispatchEvent(
      new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
    )
    expect(saveNextSpy).toHaveBeenCalled()

    saveSpy.mockClear()
    saveNextSpy.mockClear()
    isDirtySpy.mockReturnValue(true)
    hasNextSpy.mockReturnValue(false)
    document.dispatchEvent(
      new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
    )
    expect(saveNextSpy).not.toHaveBeenCalled()
    expect(saveSpy).toHaveBeenCalledWith(true)

    const closeSpy = jest.spyOn(component, 'close')
    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
    expect(closeSpy).toHaveBeenCalled()
  })

  it('selectVersion should update preview and handle preview failures', () => {
    const previewSpy = jest.spyOn(documentService, 'getPreviewUrl')
    initNormally()
    httpTestingController.expectOne(component.previewUrl()).flush('preview')

    previewSpy.mockReturnValueOnce('preview-version')
    jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-version')
    jest
      .spyOn(documentService, 'get')
      .mockReturnValue(of({ content: 'version-content' } as Document))

    component.selectVersion(10)
    httpTestingController.expectOne('preview-version').flush('version text')

    expect(component.previewUrl()).toBe('preview-version')
    expect(component.thumbUrl()).toBe('thumb-version')
    expect(component.previewText()).toBe('version text')
    expect(component.documentForm.get('content').value).toBe('version-content')
    expect(component.pdfSource()).toBe('preview-version')
    expect(component.pdfPassword()).toBeUndefined()

    previewSpy.mockReturnValueOnce('preview-error')
    component.selectVersion(11)
    httpTestingController
      .expectOne('preview-error')
      .error(new ErrorEvent('fail'))

    expect(component.previewText()).toContain(
      'An error occurred loading content'
    )
  })

  it('selectVersion should show toast if version content retrieval fails', () => {
    initNormally()
    httpTestingController.expectOne(component.previewUrl()).flush('preview')

    jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-ok')
    jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-ok')
    jest
      .spyOn(documentService, 'getMetadata')
      .mockReturnValue(of({ has_archive_version: true } as any))
    const contentError = new Error('content failed')
    jest
      .spyOn(documentService, 'get')
      .mockReturnValue(throwError(() => contentError))
    const toastSpy = jest.spyOn(toastService, 'showError')

    component.selectVersion(10)
    httpTestingController.expectOne('preview-ok').flush('preview text')

    expect(toastSpy).toHaveBeenCalledWith(
      'Error retrieving version content',
      contentError
    )
  })

  it('onVersionSelected should delegate to selectVersion', () => {
    const selectVersionSpy = jest
      .spyOn(component, 'selectVersion')
      .mockImplementation(() => {})

    component.onVersionSelected(42)

    expect(selectVersionSpy).toHaveBeenCalledWith(42)
  })

  it('onVersionsUpdated should sync open document versions and save', () => {
    component.documentId.set(doc.id)
    component.document.set({ ...doc, versions: [] } as Document)
    const updatedVersions = [
      { id: 10, is_root: false },
      { id: doc.id, is_root: true },
    ] as any
    const openDoc = { ...doc, versions: [] } as Document
    jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
    const saveSpy = jest.spyOn(openDocumentsService, 'save')

    component.onVersionsUpdated(updatedVersions)

    expect(component.document().versions).toEqual(updatedVersions)
    expect(openDoc.versions).toEqual(updatedVersions)
    expect(saveSpy).toHaveBeenCalled()
  })

  it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
    currentUserCan = false
    expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
    expect(component.createDisabled(DataType.DocumentType)).toBeTruthy()
    expect(component.createDisabled(DataType.StoragePath)).toBeTruthy()
    expect(component.createDisabled(DataType.Tag)).toBeTruthy()
  })

  it('createDisabled should return false if the user has permission to add the specified data type', () => {
    currentUserCan = true
    expect(component.createDisabled(DataType.Correspondent)).toBeFalsy()
    expect(component.createDisabled(DataType.DocumentType)).toBeFalsy()
    expect(component.createDisabled(DataType.StoragePath)).toBeFalsy()
    expect(component.createDisabled(DataType.Tag)).toBeFalsy()
  })

  it('should expose add permission via userCanAdd getter', () => {
    currentUserCan = true
    expect(component.userCanAdd).toBeTruthy()

    currentUserCan = false
    expect(component.userCanAdd).toBeFalsy()
  })

  it('should call tryRenderTiff when no archive and file is tiff', () => {
    initNormally()
    const tiffRenderSpy = jest.spyOn(
      DocumentDetailComponent.prototype as any,
      'tryRenderTiff'
    )
    const doc = Object.assign({}, component.document())
    doc.archived_file_name = null
    doc.mime_type = 'image/tiff'
    jest
      .spyOn(documentService, 'getMetadata')
      .mockReturnValue(
        of({ has_archive_version: false, original_mime_type: 'image/tiff' })
      )
    component.updateComponent(doc)
    fixture.detectChanges()
    expect(component.archiveContentRenderType).toEqual(
      component.ContentRenderType.TIFF
    )
    expect(tiffRenderSpy).toHaveBeenCalled()
  })

  it('should try to render tiff and show error if failed', () => {
    initNormally()
    // just the text request
    httpTestingController.expectOne(component.previewUrl())

    // invalid tiff
    component['tryRenderTiff']()
    httpTestingController
      .expectOne(component.previewUrl())
      .flush(new ArrayBuffer(100)) // arraybuffer
    expect(component.tiffError()).not.toBeUndefined()

    // http error
    component['tryRenderTiff']()
    httpTestingController
      .expectOne(component.previewUrl())
      .error(new ErrorEvent('failed'))
    expect(component.tiffError()).not.toBeUndefined()
  })

  it('should support download using share sheet on mobile, direct download otherwise', () => {
    const shareSpy = jest.spyOn(navigator, 'share')
    const createSpy = jest.spyOn(document, 'createElement')
    const urlRevokeSpy = jest.spyOn(URL, 'revokeObjectURL')
    initNormally()

    // Mobile
    jest.spyOn(deviceDetectorService, 'isDesktop').mockReturnValue(false)
    component.download()
    httpTestingController
      .expectOne(`${environment.apiBaseUrl}documents/${doc.id}/download/`)
      .error(new ProgressEvent('failed'))
    expect(shareSpy).not.toHaveBeenCalled()

    component.download(true)
    httpTestingController
      .expectOne(
        `${environment.apiBaseUrl}documents/${doc.id}/download/?original=true`
      )
      .flush(new ArrayBuffer(100))
    expect(shareSpy).toHaveBeenCalled()

    // Desktop
    shareSpy.mockClear()
    jest.spyOn(deviceDetectorService, 'isDesktop').mockReturnValue(true)
    component.download()
    httpTestingController
      .expectOne(`${environment.apiBaseUrl}documents/${doc.id}/download/`)
      .flush(new ArrayBuffer(100))
    expect(shareSpy).not.toHaveBeenCalled()
    expect(createSpy).toHaveBeenCalledWith('a')
    expect(urlRevokeSpy).toHaveBeenCalled()
  })

  it('should include version in download and print only for non-latest selected version', () => {
    initNormally()
    component.document().versions = [
      { id: 10, is_root: false },
      { id: doc.id, is_root: true },
    ] as any

    const getDownloadUrlSpy = jest
      .spyOn(documentService, 'getDownloadUrl')
      .mockReturnValueOnce('download-latest')
      .mockReturnValueOnce('print-latest')
      .mockReturnValueOnce('download-non-latest')
      .mockReturnValueOnce('print-non-latest')

    component.selectedVersionId.set(10)
    component.download()
    expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(
      1,
      doc.id,
      false,
      null,
      false
    )
    httpTestingController
      .expectOne('download-latest')
      .error(new ProgressEvent('failed'))

    component.printDocument()
    expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(2, doc.id, false, null)
    httpTestingController
      .expectOne('print-latest')
      .error(new ProgressEvent('failed'))

    component.selectedVersionId.set(doc.id)
    component.download()
    expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(
      3,
      doc.id,
      false,
      doc.id,
      false
    )
    httpTestingController
      .expectOne('download-non-latest')
      .error(new ProgressEvent('failed'))

    component.printDocument()
    expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(4, doc.id, false, doc.id)
    httpTestingController
      .expectOne('print-non-latest')
      .error(new ProgressEvent('failed'))
  })

  it('should omit version in download and print when no version is selected', () => {
    initNormally()
    component.document().versions = [] as any
    ;(component as any).selectedVersionId = undefined

    const getDownloadUrlSpy = jest
      .spyOn(documentService, 'getDownloadUrl')
      .mockReturnValueOnce('download-no-version')
      .mockReturnValueOnce('print-no-version')

    component.download()
    expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(
      1,
      doc.id,
      false,
      null,
      false
    )
    httpTestingController
      .expectOne('download-no-version')
      .error(new ProgressEvent('failed'))

    component.printDocument()
    expect(getDownloadUrlSpy).toHaveBeenNthCalledWith(2, doc.id, false, null)
    httpTestingController
      .expectOne('print-no-version')
      .error(new ProgressEvent('failed'))
  })

  it('should download a file with the correct filename', () => {
    const mockBlob = new Blob(['test content'], { type: 'text/plain' })
    const mockResponse = new HttpResponse({
      body: mockBlob,
      headers: new HttpHeaders({
        'Content-Disposition': 'attachment; filename="test-file.txt"',
      }),
    })

    const downloadUrl = 'http://example.com/download'
    component.documentId.set(123)
    jest.spyOn(documentService, 'getDownloadUrl').mockReturnValue(downloadUrl)

    const createSpy = jest.spyOn(document, 'createElement')
    const anchor: HTMLAnchorElement = {} as HTMLAnchorElement
    createSpy.mockReturnValueOnce(anchor)

    component.download(false)

    httpTestingController
      .expectOne(downloadUrl)
      .flush(mockBlob, { headers: mockResponse.headers })

    expect(createSpy).toHaveBeenCalledWith('a')
    expect(anchor.download).toBe('test-file.txt')
    createSpy.mockClear()
  })

  it('should get email enabled status from settings', () => {
    jest.spyOn(settingsService, 'get').mockReturnValue(true)
    expect(component.emailEnabled).toBeTruthy()
  })

  it('should support open share links and email modals', () => {
    const modalSpy = jest.spyOn(modalService, 'open')
    initNormally()
    component.selectedVersionId.set(10)
    component.openShareLinks()
    expect(modalSpy).toHaveBeenCalled()
    expect(
      (
        modalSpy.mock.results[0].value as NgbModalRef
      ).componentInstance.documentId()
    ).toBe(10)
    component.openEmailDocument()
    expect(modalSpy).toHaveBeenCalled()
    expect(
      (
        modalSpy.mock.results[1].value as NgbModalRef
      ).componentInstance.documentIds()
    ).toEqual([10])
  })

  it('should set previewText', () => {
    initNormally()
    const previewText = 'Hello world, this is a test'
    httpTestingController.expectOne(component.previewUrl()).flush(previewText)
    expect(component.previewText()).toEqual(previewText)
  })

  it('should set previewText to error message if preview fails', () => {
    initNormally()
    httpTestingController
      .expectOne(component.previewUrl())
      .flush('fail', { status: 500, statusText: 'Server Error' })
    expect(component.previewText()).toContain(
      'An error occurred loading content'
    )
  })

  it('should print document successfully', () => {
    jest.useFakeTimers()
    initNormally()

    const appendChildSpy = jest
      .spyOn(document.body, 'appendChild')
      .mockImplementation((node: Node) => node)
    const createObjectURLSpy = jest
      .spyOn(URL, 'createObjectURL')
      .mockReturnValue('blob:mock-url')
    const revokeObjectURLSpy = jest
      .spyOn(URL, 'revokeObjectURL')
      .mockImplementation(() => {})

    const mockContentWindow = {
      focus: jest.fn(),
      print: jest.fn(),
      onafterprint: null,
    }

    const mockIframe = {
      style: {},
      src: '',
      onload: null,
      contentWindow: mockContentWindow,
      remove: jest.fn(),
    }

    const createElementSpy = jest
      .spyOn(document, 'createElement')
      .mockReturnValue(mockIframe as any)

    const blob = new Blob(['test'], { type: 'application/pdf' })
    component.printDocument()

    const req = httpTestingController.expectOne(
      `${environment.apiBaseUrl}documents/${doc.id}/download/`
    )
    req.flush(blob)

    expect(createElementSpy).toHaveBeenCalledWith('iframe')
    expect(appendChildSpy).toHaveBeenCalledWith(mockIframe)
    expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
    expect(mockIframe.style).toEqual({
      position: 'fixed',
      right: '0',
      bottom: '0',
      width: '0',
      height: '0',
      border: '0',
      visibility: 'hidden',
    })

    if (mockIframe.onload) {
      mockIframe.onload({} as any)
    }
    jest.advanceTimersByTime(0)

    expect(mockContentWindow.focus).toHaveBeenCalled()
    expect(mockContentWindow.print).toHaveBeenCalled()

    if (mockIframe.onload) {
      mockIframe.onload(new Event('load'))
    }

    if (mockContentWindow.onafterprint) {
      mockContentWindow.onafterprint(new Event('afterprint'))
    }

    expect(mockIframe.remove).toHaveBeenCalled()
    expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')

    createElementSpy.mockRestore()
    appendChildSpy.mockRestore()
    createObjectURLSpy.mockRestore()
    revokeObjectURLSpy.mockRestore()
  })

  it('should show error toast if print document fails', () => {
    initNormally()
    const toastSpy = jest.spyOn(toastService, 'showError')
    component.printDocument()
    const req = httpTestingController.expectOne(
      `${environment.apiBaseUrl}documents/${doc.id}/download/`
    )
    req.error(new ErrorEvent('failed'))
    expect(toastSpy).toHaveBeenCalledWith(
      'Error loading document for printing.'
    )
  })

  const iframePrintErrorCases: Array<{
    description: string
    thrownError: Error
    expectToast: boolean
  }> = [
    {
      description: 'should show error toast if printing throws inside iframe',
      thrownError: new Error('focus failed'),
      expectToast: true,
    },
    {
      description:
        'should suppress toast if cross-origin afterprint error occurs',
      thrownError: new DOMException(
        'Accessing onafterprint triggered a cross-origin violation',
        'SecurityError'
      ),
      expectToast: false,
    },
  ]

  iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => {
    it(description, () => {
      jest.useFakeTimers()
      initNormally()

      const appendChildSpy = jest
        .spyOn(document.body, 'appendChild')
        .mockImplementation((node: Node) => node)
      const createObjectURLSpy = jest
        .spyOn(URL, 'createObjectURL')
        .mockReturnValue('blob:mock-url')
      const revokeObjectURLSpy = jest
        .spyOn(URL, 'revokeObjectURL')
        .mockImplementation(() => {})

      const toastSpy = jest.spyOn(toastService, 'showError')

      const mockContentWindow = {
        focus: jest.fn().mockImplementation(() => {
          throw thrownError
        }),
        print: jest.fn(),
        onafterprint: null,
      }

      const mockIframe: any = {
        style: {},
        src: '',
        onload: null,
        contentWindow: mockContentWindow,
        remove: jest.fn(),
      }

      const createElementSpy = jest
        .spyOn(document, 'createElement')
        .mockReturnValue(mockIframe as any)

      const blob = new Blob(['test'], { type: 'application/pdf' })
      component.printDocument()

      const req = httpTestingController.expectOne(
        `${environment.apiBaseUrl}documents/${doc.id}/download/`
      )
      req.flush(blob)

      if (mockIframe.onload) {
        mockIframe.onload(new Event('load'))
      }

      jest.advanceTimersByTime(200)

      if (expectToast) {
        expect(toastSpy).toHaveBeenCalled()
        expect(mockIframe.remove).toHaveBeenCalled()
        expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
      } else {
        expect(toastSpy).not.toHaveBeenCalled()
        expect(mockIframe.remove).not.toHaveBeenCalled()
        expect(revokeObjectURLSpy).not.toHaveBeenCalled()

        component.ngOnDestroy()

        expect(mockIframe.remove).toHaveBeenCalled()
        expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
      }

      createElementSpy.mockRestore()
      appendChildSpy.mockRestore()
      createObjectURLSpy.mockRestore()
      revokeObjectURLSpy.mockRestore()
    })
  })
})
