<script lang="ts" generics="T">
  import type { Snippet } from 'svelte';
  import { tick } from 'svelte';
  import { Loader2 } from 'lucide-svelte';

  let {
    items,
    getKey,
    item,
    hasMore = false,
    onLoadMore
  }: {
    items: T[];
    getKey: (it: T) => string;
    item: Snippet<[T]>;
    // When true, clicking a rail letter that's not in the currently
    // loaded items will trigger onLoadMore() repeatedly until that
    // letter surfaces (or no more pages exist). Lets a paginated
    // source like createArtistsQuery still feel like a full jump
    // rail without eager-loading the whole dataset.
    hasMore?: boolean;
    onLoadMore?: () => Promise<unknown> | unknown;
  } = $props();

  // Bucket the first character into one of three classes so the
  // rail can show a stable A–Z layout with '#' for digit-starting
  // entries (2 Mello, 88-Keys, etc.) and '&' for symbol-starting
  // ones ($uicideboy$, !!!, etc.). Always uppercase letters.
  function bucketFor(raw: string | undefined | null): string {
    if (!raw) return '&';
    const c = raw.charAt(0);
    if (/[0-9]/.test(c)) return '#';
    if (/[A-Za-z]/.test(c)) return c.toUpperCase();
    return '&';
  }

  // Tag the first item of each bucket so the rail can scrollIntoView
  // it. Continuous flow — no per-bucket row breaks — so packed
  // alphabets don't waste a row for single-item buckets.
  const segments = $derived.by(() => {
    const out: { bucket: string | null; item: T }[] = [];
    let last: string | null = null;
    for (const it of items) {
      const b = bucketFor(getKey(it));
      if (b !== last) {
        out.push({ bucket: b, item: it });
        last = b;
      } else {
        out.push({ bucket: null, item: it });
      }
    }
    return out;
  });

  // Buckets that contain at least one item — used to grey out the
  // rail buttons for empty letters (they're still present so the
  // rail always reads as a stable A–Z reference).
  const populated = $derived.by(() => {
    const s = new Set<string>();
    for (const it of items) s.add(bucketFor(getKey(it)));
    return s;
  });

  // Full rail: '#' (numbers) → A–Z → '&' (symbols). Always the same
  // order so muscle memory works regardless of which letters are
  // present in the current view.
  const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
  const railEntries: string[] = ['#', ...ALPHABET, '&'];

  // Bucket the caller is currently chasing via onLoadMore. Used to
  // show a spinner on the rail button while pages stream in.
  let pendingBucket = $state<string | null>(null);

  function scrollNow(bucket: string) {
    const el = document.getElementById(`alpha-${bucket}`);
    el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }

  async function jumpTo(bucket: string) {
    // Already loaded — jump in the same frame.
    if (populated.has(bucket)) {
      scrollNow(bucket);
      return;
    }
    // No paginated source wired in — nothing to load.
    if (!hasMore || !onLoadMore) return;
    // Don't stack loaders.
    if (pendingBucket !== null) return;

    pendingBucket = bucket;
    try {
      // Walk pages until the bucket appears or we exhaust the
      // dataset. `populated` is $derived from `items`; `tick()`
      // forces Svelte to flush the prop update before we re-read
      // populated for the loop condition.
      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
      while (hasMore && !populated.has(bucket)) {
        await onLoadMore();
        await tick();
      }
      if (populated.has(bucket)) scrollNow(bucket);
    } finally {
      pendingBucket = null;
    }
  }
</script>

<div class="alpha-layout">
  <div class="alpha-grid">
    {#each segments as seg, i (i)}
      <div class="cell" id={seg.bucket ? `alpha-${seg.bucket}` : undefined}>
        {@render item(seg.item)}
      </div>
    {/each}
  </div>

  {#if items.length > 0}
    <!-- Sticky vertical alphabet rail. Always shows the full
         '#' / A–Z / '&' set so the rail reads as a stable
         reference regardless of which letters are present in the
         current view; empty buckets render as disabled buttons so
         the rail layout stays muscle-memory friendly. Stays
         vertically centered via position:sticky + top:50%. -->
    <nav class="alpha-rail" aria-label="Jump to letter">
      {#each railEntries as entry}
        {@const isLoaded = populated.has(entry)}
        {@const couldLoad = !isLoaded && hasMore}
        {@const enabled = isLoaded || couldLoad}
        {@const isPending = pendingBucket === entry}
        <button
          type="button"
          class="rail-btn"
          class:disabled={!enabled}
          class:pending={isPending}
          disabled={!enabled || pendingBucket !== null}
          onclick={() => enabled && jumpTo(entry)}
          aria-label={enabled
            ? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
            : `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
          aria-busy={isPending}
        >
          {#if isPending}
            <Loader2 size={11} strokeWidth={2} class="spin" aria-hidden="true" />
          {:else}
            {entry}
          {/if}
        </button>
      {/each}
    </nav>
  {/if}
</div>

<style>
  .alpha-layout {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 16px;
    align-items: start;
  }
  .alpha-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
    gap: 16px;
    min-width: 0;
  }
  .alpha-rail {
    position: sticky;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    flex-direction: column;
    gap: 0;
    padding: 4px 2px;
    border-radius: 9999px;
    background: color-mix(in srgb, var(--fs-iron) 70%, transparent);
    backdrop-filter: blur(4px);
  }
  .rail-btn {
    width: 22px;
    height: 18px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: var(--fs-font-display);
    font-size: 11px;
    line-height: 1;
    color: var(--fs-ash);
    background: none;
    border: none;
    cursor: pointer;
    border-radius: 9999px;
  }
  .rail-btn:hover:not(.disabled),
  .rail-btn:focus-visible:not(.disabled) {
    color: var(--fs-parchment);
    background: color-mix(in srgb, var(--fs-accent) 40%, transparent);
    outline: none;
  }
  .rail-btn.disabled {
    color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
    cursor: default;
  }
  .rail-btn.pending {
    color: var(--fs-accent);
    cursor: wait;
  }
  :global(.rail-btn .spin) {
    animation: spin 1s linear infinite;
  }
  @keyframes spin {
    to { transform: rotate(360deg); }
  }
</style>
