import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { Observable, Subject, of, throwError } from 'rxjs';

import { Autocomplete } from './autocomplete';
import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types';

interface LookupItem {
  readonly code: string;
  readonly title: string;
}

const INDIA: LookupItem = { code: 'IN', title: 'India' };
const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };

@Component({
  standalone: true,
  imports: [ReactiveFormsModule, Autocomplete],
  template: `
    <app-autocomplete
      inputId="country"
      label="Country"
      [formControl]="control"
      [searchFn]="searchFn"
      [displayWith]="displayWith"
      [valueWith]="valueWith"
      placeholder="e.g.: USD"
      [selectedItem]="selectedItem()"
      [resolveValueFn]="resolveValueFn()"
      [minSearchLength]="minLength"
      [debounceTime]="delay()"
      [readonly]="readonly()"
      [showDropdownOnFocus]="openOnFocus()"
      [submitAttempted]="submitAttempted()"
    />
  `
})
class HostComponent {
  readonly control = new FormControl<string | null>(null);
  readonly selectedItem = signal<LookupItem | null>(null);
  readonly resolveValueFn = signal<AutocompleteResolveValueFn<LookupItem, string> | null>(null);
  searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]);
  readonly displayWith = (item: LookupItem): string => item.title;
  readonly valueWith = (item: LookupItem): string => item.code;
  minLength = 2;
  readonly delay = signal(300);
  readonly readonly = signal(false);
  readonly openOnFocus = signal(false);
  readonly submitAttempted = signal(false);
}

describe('Autocomplete', () => {
  let fixture: ComponentFixture<HostComponent>;
  let host: HostComponent;
  let component: Autocomplete<LookupItem, string>;
  let overlayContainer: OverlayContainer;

  const input = (): HTMLInputElement => fixture.nativeElement.querySelector('input');
  const type = (value: string): void => {
    input().value = value;
    input().dispatchEvent(new Event('input'));
    fixture.detectChanges();
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
    overlayContainer = TestBed.inject(OverlayContainer);
    fixture.detectChanges();
    component = fixture.debugElement.children[0].componentInstance;
  });

  afterEach(() => overlayContainer.ngOnDestroy());

  it('initializes with an empty selection', () => {
    expect(component.searchText()).toBe('');
    expect(component.options()).toEqual([]);
    expect(input().value).toBe('');
    expect(input().placeholder).toBe('e.g.: USD');
    expect(input().className).not.toContain('ti-form-control');
    expect(input().className).toContain('placeholder:text-textmuted');
    expect(input().className).toContain('dark:placeholder:text-white/50');
  });

  it('integrates with a reactive form control', () => {
    component.select(INDIA);
    expect(host.control.value).toBe('IN');
  });

  it('treats edited selected text as search text rather than a selected item', () => {
    component.select(INDIA);
    expect(component.activeItem()).toEqual(INDIA);
    type('Ind');
    expect(component.searchText()).toBe('Ind');
    expect(component.activeItem()).toBeNull();
  });

  it('does not emit onChange from writeValue', () => {
    const change = vi.fn();
    component.registerOnChange(change);
    component.writeValue('IN');
    expect(change).not.toHaveBeenCalled();
  });

  it('does not show invalid styling after touch before submit', () => {
    host.control.setValidators(Validators.required);
    host.control.updateValueAndValidity();
    host.control.markAsTouched();
    fixture.detectChanges();
    expect(input().classList.contains('is-invalid')).toBe(false);
  });

  it('shows invalid styling after submit and removes it when valid', () => {
    host.control.setValidators(Validators.required);
    host.control.updateValueAndValidity();
    host.submitAttempted.set(true);
    fixture.detectChanges();
    expect(input().classList.contains('is-invalid')).toBe(true);
    expect(fixture.nativeElement.textContent).toContain('Country is required.');

    host.control.setValue('IN');
    fixture.detectChanges();
    expect(input().classList.contains('is-invalid')).toBe(false);
    expect(fixture.nativeElement.textContent).not.toContain('Country is required.');
  });

  it('applies a disabled form state', () => {
    host.control.disable();
    fixture.detectChanges();
    expect(input().disabled).toBe(true);
  });

  it('waits for the configured debounce', () => {
    vi.useFakeTimers();
    const search = vi.fn(() => of([INDIA]));
    host.searchFn = search;
    fixture.detectChanges();
    type('in');
    vi.advanceTimersByTime(299);
    expect(search).not.toHaveBeenCalled();
    vi.advanceTimersByTime(1);
    expect(search).toHaveBeenCalledWith('in', 10);
    vi.useRealTimers();
  });

  it('does not search below the minimum length', () => {
    vi.useFakeTimers();
    const search = vi.fn(() => of([INDIA]));
    host.searchFn = search;
    fixture.detectChanges();
    type('i');
    vi.advanceTimersByTime(300);
    expect(search).not.toHaveBeenCalled();
    vi.useRealTimers();
  });

  it('loads five preview records on focus before the user types', () => {
    vi.useFakeTimers();
    const search = vi.fn(() => of([INDIA, INDONESIA]));
    host.openOnFocus.set(true);
    host.searchFn = search;
    fixture.detectChanges();
    input().focus();
    vi.advanceTimersByTime(0);
    fixture.detectChanges();
    expect(search).toHaveBeenCalledWith('', 5);
    expect(component.options()).toEqual([INDIA, INDONESIA]);
    expect(component.showingPreview()).toBe(true);
    expect(overlayContainer.getContainerElement().textContent).toContain('Type to search more...');
    vi.useRealTimers();
  });

  it('replaces preview records with typed search results', () => {
    vi.useFakeTimers();
    host.openOnFocus.set(true);
    host.delay.set(0);
    host.searchFn = (term, _limit) => of(term ? [INDONESIA] : [INDIA]);
    fixture.detectChanges();
    input().focus();
    vi.advanceTimersByTime(0);
    expect(component.options()).toEqual([INDIA]);
    type('in');
    vi.advanceTimersByTime(0);
    fixture.detectChanges();
    expect(component.options()).toEqual([INDONESIA]);
    expect(component.showingPreview()).toBe(false);
    expect(overlayContainer.getContainerElement().textContent).not.toContain('Type to search more...');
    vi.useRealTimers();
  });

  it('cancels stale search requests', () => {
    vi.useFakeTimers();
    const first = new Subject<readonly LookupItem[]>();
    const second = new Subject<readonly LookupItem[]>();
    host.delay.set(0);
    host.searchFn = term => term === 'in' ? first : second;
    fixture.detectChanges();
    type('in'); vi.advanceTimersByTime(0);
    type('ind'); vi.advanceTimersByTime(0);
    first.next([INDONESIA]);
    second.next([INDIA]);
    expect(component.options()).toEqual([INDIA]);
    vi.useRealTimers();
  });

  it('resets loading after a successful search', () => {
    vi.useFakeTimers();
    host.delay.set(0);
    fixture.detectChanges();
    type('in'); vi.advanceTimersByTime(0);
    expect(component.loading()).toBe(false);
    vi.useRealTimers();
  });

  it('resets loading after an error', () => {
    vi.useFakeTimers();
    host.delay.set(0);
    host.searchFn = () => throwError(() => new Error('server'));
    fixture.detectChanges();
    type('in'); vi.advanceTimersByTime(0);
    expect(component.loading()).toBe(false);
    expect(component.error()).toBe('Unable to load results');
    vi.useRealTimers();
  });

  it('recovers on a later search after an error', () => {
    vi.useFakeTimers();
    let attempts = 0;
    host.delay.set(0);
    host.searchFn = (): Observable<readonly LookupItem[]> =>
      ++attempts === 1 ? throwError(() => new Error('server')) : of([INDIA]);
    fixture.detectChanges();
    type('in'); vi.advanceTimersByTime(0);
    type('ind'); vi.advanceTimersByTime(0);
    expect(component.options()).toEqual([INDIA]);
    expect(component.error()).toBeNull();
    vi.useRealTimers();
  });

  it('renders returned options', () => {
    vi.useFakeTimers();
    host.delay.set(0);
    fixture.detectChanges();
    type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
    expect(overlayContainer.getContainerElement().textContent).toContain('India');
    vi.useRealTimers();
  });

  it('selects an option with the mouse', () => {
    vi.useFakeTimers();
    host.delay.set(0);
    fixture.detectChanges();
    type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
    overlayContainer.getContainerElement().querySelector<HTMLButtonElement>('[role="option"]')?.click();
    expect(host.control.value).toBe('IN');
    vi.useRealTimers();
  });

  it('selects the highlighted option with Enter', () => {
    component.options.set([INDIA]);
    component.open();
    input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
    input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
    expect(host.control.value).toBe('IN');
  });

  it('only highlights on arrow navigation', () => {
    component.options.set([INDIA]);
    input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
    expect(component.activeIndex()).toBe(0);
    expect(host.control.value).toBeNull();
  });

  it('closes on Escape', () => {
    component.open();
    input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
    expect(component.isOpen()).toBe(false);
  });

  it('clears the form value to null', () => {
    component.select(INDIA);
    component.clear();
    expect(host.control.value).toBeNull();
  });

  it('clears visible state when the form resets', () => {
    component.select(INDIA);
    host.control.reset();
    fixture.detectChanges();
    expect(component.searchText()).toBe('');
    expect(component.activeItem()).toBeNull();
  });

  it('displays an existing selected item', () => {
    host.selectedItem.set(INDIA);
    host.control.setValue('IN');
    fixture.detectChanges();
    expect(component.searchText()).toBe('India');
  });

  it('does not resolve an empty string form value', () => {
    const resolve = vi.fn(() => of(INDIA));
    host.resolveValueFn.set(resolve);
    fixture.detectChanges();
    host.control.setValue('');
    fixture.detectChanges();
    expect(resolve).not.toHaveBeenCalled();
  });

  it('uses the matching selected item without resolving it again', () => {
    const resolve = vi.fn(() => of(INDIA));
    host.resolveValueFn.set(resolve);
    host.selectedItem.set(INDIA);
    fixture.detectChanges();
    host.control.setValue('IN');
    fixture.detectChanges();
    expect(component.searchText()).toBe('India');
    expect(resolve).not.toHaveBeenCalled();
  });

  it('resolves a non-empty value when no matching selected item is supplied', () => {
    const resolve = vi.fn(() => of(INDIA));
    host.resolveValueFn.set(resolve);
    fixture.detectChanges();
    host.control.setValue('IN');
    fixture.detectChanges();
    expect(resolve).toHaveBeenCalledWith('IN');
    expect(component.searchText()).toBe('India');
  });

  it('does not retain a stale label when edit values change', () => {
    host.selectedItem.set(INDIA);
    host.control.setValue('IN');
    fixture.detectChanges();
    host.control.setValue('ID');
    fixture.detectChanges();
    expect(component.searchText()).toBe('');
  });

  it('closes when the overlay backdrop is clicked', () => {
    component.open();
    fixture.detectChanges();
    overlayContainer.getContainerElement().querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
    expect(component.isOpen()).toBe(false);
  });

  it('prevents modification in read-only mode', () => {
    host.readonly.set(true);
    fixture.detectChanges();
    component.select(INDIA);
    expect(host.control.value).toBeNull();
  });

  it('prevents interaction while disabled', () => {
    host.control.disable();
    fixture.detectChanges();
    component.open();
    expect(component.isOpen()).toBe(false);
  });

  it('updates combobox ARIA state', () => {
    component.open();
    fixture.detectChanges();
    expect(input().getAttribute('role')).toBe('combobox');
    expect(input().getAttribute('aria-expanded')).toBe('true');
    expect(input().getAttribute('aria-controls')).toBe('country-listbox');
  });

  it('renders the empty state', () => {
    vi.useFakeTimers();
    host.delay.set(0);
    host.searchFn = () => of([]);
    fixture.detectChanges();
    type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
    expect(overlayContainer.getContainerElement().textContent).toContain('No results found');
    vi.useRealTimers();
  });

  it('renders the friendly error state', () => {
    vi.useFakeTimers();
    host.delay.set(0);
    host.searchFn = () => throwError(() => new Error('raw error'));
    fixture.detectChanges();
    type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
    const text = overlayContainer.getContainerElement().textContent ?? '';
    expect(text).toContain('Unable to load results');
    expect(text).not.toContain('raw error');
    vi.useRealTimers();
  });
});
