// Shared client-side table controller: sorting + pagination over an already
// filtered row set. Both the Mix Editor and Ingredients Editor feed their
// filtered `visibleRows` in and render `rows` out, so the two surfaces stay
// behaviourally identical. Keeping the page math here (rather than ad-hoc
// `$effect`s per page) means navigation can't get clamped back to page 1.

export type SortDir = 'asc' | 'desc';
export type SortValue = string | number | boolean | null | undefined;
export type Accessor<T> = (row: T) => SortValue;

const PAGE_SIZES = [10, 25, 50, 100] as const;

function isEmpty(value: SortValue): boolean {
  return value === null || value === undefined || value === '';
}

function compareNonEmpty(a: SortValue, b: SortValue): number {
  if (typeof a === 'number' && typeof b === 'number') return a - b;
  if (typeof a === 'boolean' && typeof b === 'boolean') {
    return a === b ? 0 : a ? -1 : 1;
  }
  return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}

export class TableController<T> {
  readonly pageSizes = PAGE_SIZES;

  page = $state(1);
  pageSize = $state(25);
  sortKey = $state<string | null>(null);
  sortDir = $state<SortDir>('asc');

  private readonly source: () => readonly T[];
  private readonly accessors: Record<string, Accessor<T>>;

  constructor(source: () => readonly T[], accessors: Record<string, Accessor<T>> = {}) {
    this.source = source;
    this.accessors = accessors;
  }

  readonly sorted = $derived.by<readonly T[]>(() => {
    const rows = this.source();
    const accessor = this.sortKey ? this.accessors[this.sortKey] : undefined;
    if (!accessor) return rows;
    const dir = this.sortDir === 'asc' ? 1 : -1;
    // Empties always sort last, regardless of direction.
    return [...rows].sort((a, b) => {
      const av = accessor(a);
      const bv = accessor(b);
      const aEmpty = isEmpty(av);
      const bEmpty = isEmpty(bv);
      if (aEmpty || bEmpty) {
        if (aEmpty && bEmpty) return 0;
        return aEmpty ? 1 : -1;
      }
      return compareNonEmpty(av, bv) * dir;
    });
  });

  readonly total = $derived(this.sorted.length);
  readonly totalPages = $derived(Math.max(1, Math.ceil(this.total / this.pageSize)));
  // The page we actually show. Derived clamping means shrinking the result set
  // (via filters or a larger page size) can never strand the view on an empty page.
  readonly currentPage = $derived(Math.min(Math.max(1, this.page), this.totalPages));
  readonly pageStart = $derived(this.total === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1);
  readonly pageEnd = $derived(Math.min(this.total, this.currentPage * this.pageSize));
  readonly rows = $derived(this.sorted.slice(this.pageStart === 0 ? 0 : this.pageStart - 1, this.pageEnd));
  readonly canPrev = $derived(this.currentPage > 1);
  readonly canNext = $derived(this.currentPage < this.totalPages);

  toggleSort(key: string): void {
    if (!this.accessors[key]) return;
    if (this.sortKey === key) {
      this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
    } else {
      this.sortKey = key;
      this.sortDir = 'asc';
    }
    this.page = 1;
  }

  setPage(next: number): void {
    this.page = Math.min(Math.max(1, next), this.totalPages);
  }

  next(): void {
    this.setPage(this.currentPage + 1);
  }

  prev(): void {
    this.setPage(this.currentPage - 1);
  }

  reset(): void {
    this.page = 1;
  }
}
