import {Directionality} from '@angular/cdk/bidi';
import {ENTER, SPACE} from '@angular/cdk/keycodes';
import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing/private';
import {Component, DebugElement, ViewChild} from '@angular/core';
import {ComponentFixture, TestBed, fakeAsync, flush, waitForAsync} from '@angular/core/testing';
import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core';
import {By} from '@angular/platform-browser';
import {Subject} from 'rxjs';
import {
  MAT_CHIPS_DEFAULT_OPTIONS,
  MatChipEvent,
  MatChipListbox,
  MatChipOption,
  MatChipSelectionChange,
  MatChipsDefaultOptions,
  MatChipsModule,
} from './index';

describe('Option Chips', () => {
  let fixture: ComponentFixture<any>;
  let chipDebugElement: DebugElement;
  let chipNativeElement: HTMLElement;
  let primaryAction: HTMLElement;
  let chipInstance: MatChipOption;
  let globalRippleOptions: RippleGlobalOptions;
  let dir = 'ltr';

  let hideSingleSelectionIndicator: boolean | undefined;

  beforeEach(waitForAsync(() => {
    globalRippleOptions = {};
    const defaultOptions: MatChipsDefaultOptions = {
      separatorKeyCodes: [ENTER, SPACE],
      hideSingleSelectionIndicator,
    };

    TestBed.configureTestingModule({
      imports: [MatChipsModule],
      providers: [
        {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions},
        {
          provide: Directionality,
          useFactory: () => ({
            value: dir,
            change: new Subject(),
          }),
        },
        {provide: MAT_CHIPS_DEFAULT_OPTIONS, useFactory: () => defaultOptions},
      ],
      declarations: [SingleChip],
    });
  }));

  describe('MatChipOption', () => {
    let testComponent: SingleChip;

    beforeEach(() => {
      fixture = TestBed.createComponent(SingleChip);
      fixture.detectChanges();

      chipDebugElement = fixture.debugElement.query(By.directive(MatChipOption))!;
      chipNativeElement = chipDebugElement.nativeElement;
      chipInstance = chipDebugElement.injector.get<MatChipOption>(MatChipOption);
      primaryAction = chipNativeElement.querySelector('.mdc-evolution-chip__action--primary')!;
      testComponent = fixture.debugElement.componentInstance;
    });

    describe('basic behaviors', () => {
      it('adds the `mat-chip` class', () => {
        expect(chipNativeElement.classList).toContain('mat-mdc-chip');
      });

      it('emits focus only once for multiple clicks', () => {
        let counter = 0;
        chipInstance._onFocus.subscribe(() => {
          counter++;
        });

        primaryAction.focus();
        primaryAction.focus();
        fixture.detectChanges();

        expect(counter).toBe(1);
      });

      it('emits destroy on destruction', () => {
        spyOn(testComponent, 'chipDestroy').and.callThrough();

        // Force a destroy callback
        testComponent.shouldShow = false;
        fixture.changeDetectorRef.markForCheck();
        fixture.detectChanges();

        expect(testComponent.chipDestroy).toHaveBeenCalledTimes(1);
      });

      it('allows color customization', () => {
        expect(chipNativeElement.classList).toContain('mat-primary');

        testComponent.color = 'warn';
        fixture.changeDetectorRef.markForCheck();
        fixture.detectChanges();

        expect(chipNativeElement.classList).not.toContain('mat-primary');
        expect(chipNativeElement.classList).toContain('mat-warn');
      });

      it('allows selection', () => {
        spyOn(testComponent, 'chipSelectionChange');
        expect(chipNativeElement.classList).not.toContain('mat-mdc-chip-selected');

        testComponent.selected = true;
        fixture.changeDetectorRef.markForCheck();
        fixture.detectChanges();

        expect(chipNativeElement.classList).toContain('mat-mdc-chip-selected');
        expect(testComponent.chipSelectionChange).toHaveBeenCalledWith({
          source: chipInstance,
          isUserInput: false,
          selected: true,
        });
      });

      it('should not prevent the default click action', () => {
        const event = dispatchFakeEvent(chipNativeElement, 'click');
        fixture.detectChanges();

        expect(event.defaultPrevented).toBe(false);
      });

      it('should not dispatch `selectionChange` event when deselecting a non-selected chip', () => {
        chipInstance.deselect();

        const spy = jasmine.createSpy('selectionChange spy');
        const subscription = chipInstance.selectionChange.subscribe(spy);

        chipInstance.deselect();

        expect(spy).not.toHaveBeenCalled();
        subscription.unsubscribe();
      });

      it('should not dispatch `selectionChange` event when selecting a selected chip', () => {
        chipInstance.select();

        const spy = jasmine.createSpy('selectionChange spy');
        const subscription = chipInstance.selectionChange.subscribe(spy);

        chipInstance.select();

        expect(spy).not.toHaveBeenCalled();
        subscription.unsubscribe();
      });

      it(
        'should not dispatch `selectionChange` event when selecting a selected chip via ' +
          'user interaction',
        () => {
          chipInstance.select();

          const spy = jasmine.createSpy('selectionChange spy');
          const subscription = chipInstance.selectionChange.subscribe(spy);

          chipInstance.selectViaInteraction();

          expect(spy).not.toHaveBeenCalled();
          subscription.unsubscribe();
        },
      );

      it('should not dispatch `selectionChange` through setter if the value did not change', () => {
        chipInstance.selected = false;

        const spy = jasmine.createSpy('selectionChange spy');
        const subscription = chipInstance.selectionChange.subscribe(spy);

        chipInstance.selected = false;

        expect(spy).not.toHaveBeenCalled();
        subscription.unsubscribe();
      });

      it('should be able to disable ripples through ripple global options at runtime', () => {
        expect(chipInstance._isRippleDisabled())
          .withContext('Expected chip ripples to be enabled.')
          .toBe(false);

        globalRippleOptions.disabled = true;

        expect(chipInstance._isRippleDisabled())
          .withContext('Expected chip ripples to be disabled.')
          .toBe(true);
      });

      it('should have the correct role', () => {
        expect(chipNativeElement.getAttribute('role')).toBe('presentation');
      });

      it('should be able to set a custom role', () => {
        chipInstance.role = 'button';
        fixture.changeDetectorRef.markForCheck();
        fixture.detectChanges();

        expect(chipNativeElement.getAttribute('role')).toBe('button');
      });
    });

    describe('keyboard behavior', () => {
      describe('when selectable is true', () => {
        beforeEach(() => {
          testComponent.selectable = true;
          fixture.changeDetectorRef.markForCheck();
          fixture.detectChanges();
        });

        it('should selects/deselects the currently focused chip on SPACE', () => {
          const CHIP_SELECTED_EVENT: MatChipSelectionChange = {
            source: chipInstance,
            isUserInput: true,
            selected: true,
          };

          const CHIP_DESELECTED_EVENT: MatChipSelectionChange = {
            source: chipInstance,
            isUserInput: true,
            selected: false,
          };

          spyOn(testComponent, 'chipSelectionChange');

          // Use the spacebar to select the chip
          dispatchKeyboardEvent(primaryAction, 'keydown', SPACE);
          fixture.detectChanges();

          expect(chipInstance.selected).toBeTruthy();
          expect(testComponent.chipSelectionChange).toHaveBeenCalledTimes(1);
          expect(testComponent.chipSelectionChange).toHaveBeenCalledWith(CHIP_SELECTED_EVENT);

          // Use the spacebar to deselect the chip
          dispatchKeyboardEvent(primaryAction, 'keydown', SPACE);
          fixture.detectChanges();

          expect(chipInstance.selected).toBeFalsy();
          expect(testComponent.chipSelectionChange).toHaveBeenCalledTimes(2);
          expect(testComponent.chipSelectionChange).toHaveBeenCalledWith(CHIP_DESELECTED_EVENT);
        });

        it('should have correct aria-selected in single selection mode', () => {
          expect(primaryAction.getAttribute('aria-selected')).toBe('false');

          testComponent.selected = true;
          fixture.changeDetectorRef.markForCheck();
          fixture.detectChanges();

          expect(primaryAction.getAttribute('aria-selected')).toBe('true');
        });

        it('should have the correct aria-selected in multi-selection mode', fakeAsync(() => {
          testComponent.chipList.multiple = true;
          fixture.changeDetectorRef.markForCheck();
          flush();
          fixture.detectChanges();
          expect(primaryAction.getAttribute('aria-selected')).toBe('false');

          testComponent.selected = true;
          fixture.changeDetectorRef.markForCheck();
          fixture.detectChanges();

          expect(primaryAction.getAttribute('aria-selected')).toBe('true');
        }));

        it('should disable focus on the checkmark', fakeAsync(() => {
          // The checkmark is only shown in multi selection mode.
          testComponent.chipList.multiple = true;
          fixture.changeDetectorRef.markForCheck();
          flush();
          fixture.detectChanges();

          const checkmark = chipNativeElement.querySelector('.mdc-evolution-chip__checkmark-svg')!;
          expect(checkmark.getAttribute('focusable')).toBe('false');
        }));
      });

      describe('when selectable is false', () => {
        beforeEach(() => {
          testComponent.selectable = false;
          fixture.changeDetectorRef.markForCheck();
          fixture.detectChanges();
        });

        it('SPACE ignores selection', () => {
          spyOn(testComponent, 'chipSelectionChange');

          // Use the spacebar to attempt to select the chip
          dispatchKeyboardEvent(primaryAction, 'keydown', SPACE);
          fixture.detectChanges();

          expect(chipInstance.selected).toBe(false);
          expect(testComponent.chipSelectionChange).not.toHaveBeenCalled();
        });

        it('should not have the aria-selected attribute', () => {
          expect(primaryAction.hasAttribute('aria-selected')).toBe(false);
        });
      });

      it('should update the aria-disabled for disabled chips', () => {
        expect(primaryAction.getAttribute('aria-disabled')).toBe('false');

        testComponent.disabled = true;
        fixture.changeDetectorRef.markForCheck();
        fixture.detectChanges();

        expect(primaryAction.getAttribute('aria-disabled')).toBe('true');
      });

      it('should display checkmark graphic by default', () => {
        expect(
          fixture.debugElement.injector.get(MAT_CHIPS_DEFAULT_OPTIONS)
            ?.hideSingleSelectionIndicator,
        )
          .withContext(
            'expected not to have a default value set for `hideSingleSelectionIndicator`',
          )
          .toBeUndefined();

        expect(chipNativeElement.querySelector('.mat-mdc-chip-graphic')).toBeTruthy();
        expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-graphic');
      });
    });

    describe('a11y', () => {
      it('should apply `ariaLabel` and `ariaDesciption` to the element with option role', () => {
        testComponent.ariaLabel = 'option name';
        testComponent.ariaDescription = 'option description';
        fixture.changeDetectorRef.markForCheck();

        fixture.detectChanges();

        const optionElement = fixture.nativeElement.querySelector('[role="option"]') as HTMLElement;
        expect(optionElement)
          .withContext('expected to find an element with option role')
          .toBeTruthy();

        expect(optionElement.getAttribute('aria-label')).toMatch(/option name/i);

        const optionElementDescribedBy = optionElement!.getAttribute('aria-describedby');
        expect(optionElementDescribedBy)
          .withContext('expected primary grid cell to have a non-empty aria-describedby attribute')
          .toBeTruthy();

        const optionElementDescriptions = Array.from(
          (fixture.nativeElement as HTMLElement).querySelectorAll(
            optionElementDescribedBy!
              .split(/\s+/g)
              .map(x => `#${x}`)
              .join(','),
          ),
        );

        const optionElementDescription = optionElementDescriptions
          .map(x => x.textContent?.trim())
          .join(' ')
          .trim();

        expect(optionElementDescription).toMatch(/option description/i);
      });

      it('should display checkmark graphic by default', () => {
        expect(chipNativeElement.querySelector('.mat-mdc-chip-graphic')).toBeTruthy();
        expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-graphic');
      });
    });

    describe('with token to hide single-selection checkmark indicator', () => {
      beforeAll(() => {
        hideSingleSelectionIndicator = true;
      });

      afterAll(() => {
        hideSingleSelectionIndicator = undefined;
      });

      it('does not display checkmark graphic', () => {
        expect(chipNativeElement.querySelector('.mat-mdc-chip-graphic')).toBeNull();
        expect(chipNativeElement.classList).not.toContain(
          'mdc-evolution-chip--with-primary-graphic',
        );
      });

      it('displays checkmark graphic when avatar is provided', () => {
        testComponent.selected = true;
        testComponent.avatarLabel = 'A';
        fixture.changeDetectorRef.markForCheck();
        fixture.detectChanges();

        expect(chipNativeElement.querySelector('.mat-mdc-chip-graphic')).toBeTruthy();
        expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-graphic');
      });
    });

    it('should contain a focus indicator inside the text label', () => {
      const label = chipNativeElement.querySelector('.mdc-evolution-chip__text-label');
      expect(label?.querySelector('.mat-focus-indicator')).toBeTruthy();
    });
  });
});

@Component({
  template: `
    <mat-chip-listbox>
      @if (shouldShow) {
        <div>
          <mat-chip-option [selectable]="selectable"
                  [color]="color" [selected]="selected" [disabled]="disabled"
                  (destroyed)="chipDestroy($event)"
                  (selectionChange)="chipSelectionChange($event)"
                  [aria-label]="ariaLabel" [aria-description]="ariaDescription">
            @if (avatarLabel) {
              <span class="avatar" matChipAvatar>{{avatarLabel}}</span>
            }
            {{name}}
          </mat-chip-option>
        </div>
      }
    </mat-chip-listbox>`,
  standalone: false,
})
class SingleChip {
  @ViewChild(MatChipListbox) chipList: MatChipListbox;
  disabled: boolean = false;
  name: string = 'Test';
  color: string = 'primary';
  selected: boolean = false;
  selectable: boolean = true;
  shouldShow: boolean = true;
  ariaLabel: string | null = null;
  ariaDescription: string | null = null;
  avatarLabel: string | null = null;

  chipDestroy: (event?: MatChipEvent) => void = () => {};
  chipSelectionChange: (event?: MatChipSelectionChange) => void = () => {};
}
