import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  effect,
  ElementRef,
  inject,
  input,
  OnInit,
  output,
  viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
import { HlmSpinnerImports } from '@spartan-ng/helm/spinner';
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
import type { Option } from '../types';

/**
 * Async, debounced, searchable multi-select for many-to-many relationships
 * with many options (>500). Pure input/output. Host loads filtered options in
 * response to `searchChange`.
 *
 * For sync multi-select (≤500 options), see `<au-multiple-search-select>`.
 *
 * @example
 * <au-async-multiple-search-select
 *   [inputId]="'tagIds'"
 *   [value]="form.controls.tagIds.value ?? []"
 *   [options]="tagOptions()"
 *   [loading]="tagsLoading()"
 *   [emptyMessage]="t('Aurora.NoResults')"
 *   (valueChange)="form.controls.tagIds.setValue($event)"
 *   (searchChange)="onTagSearch($event)"
 *   (touched)="form.controls.tagIds.markAsTouched()"
 * />
 */
@Component({
  selector: 'au-async-multiple-search-select',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [HlmComboboxImports, HlmSpinnerImports],
  host: { class: 'block' },
  template: `
    <hlm-combobox-multiple
      class="w-full"
      [value]="selectedOptions()"
      [disabled]="disabled()"
      [filter]="noopFilter"
      [autoHighlight]="false"
      [itemToString]="optionToString"
      [isItemEqualToValue]="optionEqual"
      (valueChange)="onValueChange($event)"
      (searchChange)="onSearchChange($event)"
      (closed)="touched.emit()"
    >
      <hlm-combobox-chips class="w-full">
        <ng-template
          hlmComboboxValues
          let-values
        >
          @for (selected of values; track selected.value) {
            <hlm-combobox-chip [value]="selected">{{ selected.label }}</hlm-combobox-chip>
          }
        </ng-template>
        <input
          [id]="inputId()"
          [placeholder]="placeholder()"
          hlmComboboxChipInput
        />
      </hlm-combobox-chips>
      <hlm-combobox-content *hlmComboboxPortal>
        <hlm-combobox-empty>{{ emptyMessage() }}</hlm-combobox-empty>
        <div hlmComboboxList>
          @for (opt of options(); track opt.value) {
            <hlm-combobox-item
              [value]="opt"
              [disabled]="opt.disabled ?? false"
            >
              {{ opt.label }}
            </hlm-combobox-item>
          }
          @if (loading()) {
            <div
              aria-live="polite"
              class="flex justify-center py-2"
            >
              <hlm-spinner class="text-sm" />
            </div>
          }
          <div
            #loadMoreSentinel
            aria-hidden="true"
            class="h-1 w-full"
          ></div>
        </div>
      </hlm-combobox-content>
    </hlm-combobox-multiple>

    @if (errorMessage(); as err) {
      <p class="text-destructive mt-1 text-xs">{{ err }}</p>
    }
  `,
})
export class AsyncMultipleSearchSelect implements OnInit {
  private readonly destroyRef = inject(DestroyRef);

  readonly value = input<string[]>([]);
  readonly options = input<Option[]>([]);
  readonly loading = input<boolean>(false);
  readonly inputId = input<string>('');
  readonly placeholder = input<string>('Search...');
  readonly disabled = input<boolean>(false);
  readonly emptyMessage = input<string>('No results');
  readonly errorMessage = input<string | null>(null);
  readonly debounce = input<number>(300);

  readonly valueChange = output<string[]>();
  readonly searchChange = output<string>();
  readonly loadMore = output<void>();
  readonly touched = output<void>();

  protected readonly loadMoreSentinel = viewChild<ElementRef<HTMLElement>>('loadMoreSentinel');

  protected readonly selectedOptions = computed<Option[]>(() => {
    const ids = this.value();
    return this.options().filter((o) => ids.includes(o.value));
  });

  protected readonly noopFilter = () => true;
  protected readonly optionToString = (opt: Option | null) => opt?.label ?? '';
  protected readonly optionEqual = (a: Option | null, b: Option | null) => a?.value === b?.value;

  private readonly search$ = new Subject<string>();

  constructor() {
    // Sentinel is rendered inside the popover portal; it (re)mounts every time
    // the dropdown opens. The effect re-runs when the viewChild signal changes
    // and rewires the IntersectionObserver against the new sentinel.
    effect((onCleanup) => {
      const sentinel = this.loadMoreSentinel()?.nativeElement;
      if (!sentinel) return;
      const observer = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting && this.options().length > 0) {
              this.loadMore.emit();
            }
          }
        },
        { root: sentinel.parentElement, rootMargin: '0px', threshold: 0.1 },
      );
      observer.observe(sentinel);
      onCleanup(() => observer.disconnect());
    });
  }

  ngOnInit(): void {
    this.search$
      .pipe(
        debounceTime(this.debounce()),
        distinctUntilChanged(),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe((value) => this.searchChange.emit(value));
  }

  protected onValueChange(options: Option[] | null): void {
    this.valueChange.emit((options ?? []).map((o) => o.value));
  }

  protected onSearchChange(value: string): void {
    this.search$.next(value);
  }
}
