import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  signal,
  untracked,
} from '@angular/core';
import { Router } from '@angular/router';
import { TranslocoDirective } from '@jsverse/transloco';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
  lucideColumns3,
  lucideGripVertical,
  lucideLock,
  lucideLockOpen,
  lucideTriangleAlert,
} from '@ng-icons/lucide';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCheckboxImports } from '@spartan-ng/helm/checkbox';
import { HlmIconImports } from '@spartan-ng/helm/icon';
import { HlmPopoverImports } from '@spartan-ng/helm/popover';
import type {
  ColumnOrderState,
  ColumnPinningState,
  Table,
  VisibilityState,
} from '@tanstack/angular-table';
import type { DataTableColumnDef, LabeledColumn } from '../types';

interface ColumnToggleItem extends LabeledColumn {
  visible: boolean;
  pinned: boolean;
  canHide: boolean;
  canUnpin: boolean;
}

interface PersistedTableState {
  columnPinning: ColumnPinningState;
  columnVisibility: VisibilityState;
  columnOrder: ColumnOrderState;
}

const STORAGE_KEY_PREFIX = 'au-data-table-state';
const PINNED_WIDTH_WARNING_RATIO = 0.5;

/**
 * Column manager popover. Lets the user toggle column visibility, reorder
 * columns within their zone (sticky-left or scrollable), pin/unpin columns
 * to the left edge, and reset to declared defaults.
 *
 * Pinning model:
 *  - Columns whose `DataTableColumnDef` declared `sticky: 'left'|'right'` at
 *    construction time are HARD locks: their lock indicator is non-clickable
 *    and visually distinguished (lock with a dot). The user CANNOT unpin
 *    these from the toggle UI.
 *  - Columns the user pins at runtime are SOFT locks: clickable, can be
 *    toggled on/off freely.
 *  - Pinning is applied via TanStack's `column.pin('left'|false)`. The
 *    underlying `<au-data-table>` reads `column.getIsPinned()` directly,
 *    so the toggle state and the table render are always coherent.
 *
 * Drag and drop: rendered as TWO independent `cdkDropList`s separated by
 * a divider. They are NOT connected (`cdkDropListConnectedTo` omitted) so
 * CDK rejects cross-zone drops automatically. Cross-zone transitions
 * happen exclusively via the lock toggle.
 *
 * Persistence: pinning + visibility + order are serialized to
 * `localStorage` under the key `au-data-table-state:<route>:<tableId>`.
 * On init, persisted state is restored before the sync effect runs. Reset
 * removes the storage entry and restores the defaults derived from the
 * column definitions.
 */
@Component({
  selector: 'au-column-toggle',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    DragDropModule,
    NgIcon,
    TranslocoDirective,
    HlmButtonImports,
    HlmCheckboxImports,
    HlmIconImports,
    HlmPopoverImports,
  ],
  providers: [
    provideIcons({
      lucideColumns3,
      lucideGripVertical,
      lucideLock,
      lucideLockOpen,
      lucideTriangleAlert,
    }),
  ],
  host: { class: 'block' },
  template: `
    <hlm-popover
      *transloco="let t"
      align="end"
    >
      <button
        hlmPopoverTrigger
        hlmBtn
        variant="outline"
        size="sm"
      >
        <ng-icon
          name="lucideColumns3"
          class="sm:mr-1"
          hlm
          size="sm"
        />
        <span class="hidden sm:inline">{{ t('Columns') }}</span>
      </button>
      <hlm-popover-content
        *hlmPopoverPortal
        class="w-64 p-4"
      >
        <div class="space-y-3">
          <div class="flex items-center justify-between gap-2">
            <h4 class="text-sm font-medium">{{ t('ManageColumns') }}</h4>
            @if (showPinningWarning()) {
              <span
                class="text-warning flex items-center gap-1 text-xs"
                [attr.title]="t('PinnedColumnsWarning')"
              >
                <ng-icon
                  name="lucideTriangleAlert"
                  hlm
                  size="xs"
                />
                {{ pinnedWidthPercent() }}%
              </span>
            }
          </div>
          <div class="max-h-[300px] space-y-1 overflow-y-auto">
            <!-- Sticky zone -->
            @if (stickyItems().length > 0) {
              <div
                cdkDropList
                (cdkDropListDropped)="onDropSticky($event)"
              >
                @for (item of stickyItems(); track item.key) {
                  <div
                    class="hover:bg-accent flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors"
                    cdkDrag
                  >
                    <ng-icon
                      name="lucideGripVertical"
                      class="text-muted-foreground cursor-grab"
                      hlm
                      size="xs"
                    />
                    <hlm-checkbox
                      [checked]="item.visible"
                      [disabled]="!item.canHide"
                      (checkedChange)="toggleVisibility(item.key)"
                    />
                    <span class="flex-1 truncate text-sm">{{ item.label }}</span>
                    @if (item.canUnpin) {
                      <button
                        type="button"
                        class="bg-foreground/8 text-foreground hover:bg-foreground/15 inline-flex size-6 items-center justify-center rounded-full transition-colors"
                        [attr.aria-label]="t('Unpin')"
                        (click)="toggleSticky(item.key)"
                      >
                        <ng-icon
                          name="lucideLock"
                          hlm
                          size="xs"
                        />
                      </button>
                    } @else {
                      <span
                        class="bg-foreground/8 text-foreground/70 relative inline-flex size-6 items-center justify-center rounded-full"
                        [attr.aria-label]="t('PinnedLocked')"
                        [attr.title]="t('PinnedLocked')"
                      >
                        <ng-icon
                          name="lucideLock"
                          hlm
                          size="xs"
                        />
                        <span
                          class="bg-foreground/70 absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full"
                        ></span>
                      </span>
                    }
                  </div>
                }
              </div>
              <div class="border-border/60 mx-1 my-2 border-t"></div>
            }
            <!-- Scroll zone -->
            <div
              cdkDropList
              (cdkDropListDropped)="onDropScroll($event)"
            >
              @for (item of scrollItems(); track item.key) {
                <div
                  class="hover:bg-accent flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors"
                  cdkDrag
                >
                  <ng-icon
                    name="lucideGripVertical"
                    class="text-muted-foreground cursor-grab"
                    hlm
                    size="xs"
                  />
                  <hlm-checkbox
                    [checked]="item.visible"
                    [disabled]="!item.canHide"
                    (checkedChange)="toggleVisibility(item.key)"
                  />
                  <span class="flex-1 truncate text-sm">{{ item.label }}</span>
                  <button
                    type="button"
                    class="text-muted-foreground hover:bg-muted hover:text-foreground inline-flex size-6 items-center justify-center rounded-full transition-colors"
                    [attr.aria-label]="t('Pin')"
                    (click)="toggleSticky(item.key)"
                  >
                    <ng-icon
                      name="lucideLockOpen"
                      hlm
                      size="xs"
                    />
                  </button>
                </div>
              }
            </div>
          </div>
          <button
            class="text-muted-foreground hover:text-foreground w-full text-xs"
            hlmBtn
            variant="ghost"
            size="sm"
            (click)="reset()"
          >
            {{ t('ResetToDefault') }}
          </button>
        </div>
      </hlm-popover-content>
    </hlm-popover>
  `,
})
export class ColumnToggle<T> {
  // injections
  private readonly router = inject(Router);

  // inputs
  readonly table = input.required<Table<T>>();
  readonly columns = input.required<LabeledColumn[]>();
  /**
   * Optional identifier disambiguating multiple tables on the same route.
   * Default: empty string. Persistence key is
   * `au-data-table-state:<route>:<tableId>`. Two `<au-column-toggle>`s on
   * the same route MUST receive different `tableId` values to persist
   * independently.
   */
  readonly tableId = input<string>('');

  // signals
  protected readonly toggleItems = signal<ColumnToggleItem[]>([]);

  /**
   * Snapshot of column ids that were declared with `sticky: 'left' | 'right'`
   * at the time the toggle initialized. Drives `canUnpin` (false for these,
   * true for everything else). Captured once at first render so subsequent
   * runtime pin changes by the user don't bleed into hard-lock status.
   */
  private readonly declaredStickyIds = new Set<string>();

  /** Default state captured at first render, used by `reset()`. */
  private defaultPinning: ColumnPinningState = { left: [], right: [] };
  private defaultVisibility: VisibilityState = {};
  private defaultOrder: ColumnOrderState = [];

  /** True once we've performed the localStorage restore + default snapshot. */
  private initialized = false;

  // computeds
  /**
   * Sticky-zone items ordered by `columnPinning.left` — NOT by `columnOrder`.
   * TanStack stores the pinned-left visual order in `columnPinning.left`;
   * `columnOrder` only governs the unpinned portion. Without this ordering
   * the popover would render pinned items in their original declaration
   * order even after the user reorders them.
   */
  protected readonly stickyItems = computed(() => {
    const items = this.toggleItems();
    const itemMap = new Map(items.map((item) => [item.key, item]));
    const leftOrder = this.table().getState().columnPinning.left ?? [];
    return leftOrder
      .map((id) => itemMap.get(id))
      .filter((item): item is ColumnToggleItem => item !== undefined);
  });

  protected readonly scrollItems = computed(() =>
    this.toggleItems().filter((item) => !item.pinned),
  );

  protected readonly pinnedWidthRatio = computed(() => {
    const pinnedColumns = this.table().getLeftLeafColumns();
    if (pinnedColumns.length === 0) return 0;
    const totalPinned = pinnedColumns.reduce((acc, c) => acc + c.getSize(), 0);
    const viewport = typeof window !== 'undefined' ? window.innerWidth : 0;
    return viewport > 0 ? totalPinned / viewport : 0;
  });

  protected readonly showPinningWarning = computed(
    () => this.pinnedWidthRatio() > PINNED_WIDTH_WARNING_RATIO,
  );

  protected readonly pinnedWidthPercent = computed(() => Math.round(this.pinnedWidthRatio() * 100));

  constructor() {
    effect(() => {
      const table = this.table();
      const labelMap = new Map(this.columns().map((c) => [c.key, c.label]));
      // Track the live pinning state so the effect re-runs when the user pins/unpins.
      const pinningState = table.getState().columnPinning;
      const visibilityState = table.getState().columnVisibility;
      const orderState = table.getState().columnOrder;

      untracked(() => {
        if (!this.initialized) {
          this.initialize(table, labelMap, pinningState, visibilityState, orderState);
          return;
        }
        // Subsequent runs: rebuild items from live state, preserving the
        // hard-lock snapshot we took at initialization.
        this.rebuildItems(table, labelMap);
        this.persist(table);
      });
    });
  }

  protected onDropSticky(event: CdkDragDrop<ColumnToggleItem[]>) {
    const sticky = [...this.stickyItems()];
    moveItemInArray(sticky, event.previousIndex, event.currentIndex);
    this.applyZoneReorder(
      sticky.map((i) => i.key),
      this.scrollItems().map((i) => i.key),
    );
  }

  protected onDropScroll(event: CdkDragDrop<ColumnToggleItem[]>) {
    const scroll = [...this.scrollItems()];
    moveItemInArray(scroll, event.previousIndex, event.currentIndex);
    this.applyZoneReorder(
      this.stickyItems().map((i) => i.key),
      scroll.map((i) => i.key),
    );
  }

  /**
   * Applies a new order for both zones to TanStack. Two writes happen:
   * 1. `setColumnPinning({ left: stickyOrder, right })` — keeps the pinning
   *    set in sync (it's an idempotent rewrite when no pin/unpin happened,
   *    but it also propagates the order so `column.getStart('left')`
   *    accumulates offsets in the new sequence).
   * 2. `setColumnOrder([...stickyOrder, ...scrollOrder, ...right])` — this
   *    is what TanStack actually consults to determine the visual order of
   *    each column. Without this write the visual order falls back to the
   *    declaration order in the `columns` array, ignoring the user reorder.
   */
  private applyZoneReorder(stickyOrder: string[], scrollOrder: string[]): void {
    const table = this.table();
    const currentRight = table.getState().columnPinning.right ?? [];
    table.setColumnPinning({ left: stickyOrder, right: currentRight });
    table.setColumnOrder([...stickyOrder, ...scrollOrder, ...currentRight]);
  }

  protected toggleVisibility(key: string) {
    const column = this.table().getColumn(key);
    if (!column?.getCanHide()) return;
    column.toggleVisibility();
  }

  protected toggleSticky(key: string) {
    const column = this.table().getColumn(key);
    if (!column) return;
    const isPinnedLeft = column.getIsPinned() === 'left';
    if (isPinnedLeft && !this.canUnpinId(key)) return; // hard lock guard
    column.pin(isPinnedLeft ? false : 'left');
  }

  protected reset() {
    this.removeStorage();
    this.table().setColumnPinning(this.defaultPinning);
    this.table().setColumnVisibility(this.defaultVisibility);
    this.table().setColumnOrder(this.defaultOrder);
  }

  // ─── private ────────────────────────────────────────────────

  private initialize(
    table: Table<T>,
    labelMap: Map<string, string>,
    pinningState: ColumnPinningState,
    visibilityState: VisibilityState,
    orderState: ColumnOrderState,
  ): void {
    // Snapshot the hard-lock set from the original column definitions —
    // any column that declared `sticky` at construction time is unpin-locked.
    for (const column of table.getAllColumns()) {
      const colDef = column.columnDef as DataTableColumnDef<T>;
      if (colDef.sticky === 'left' || colDef.sticky === 'right') {
        this.declaredStickyIds.add(column.id);
      }
    }

    // Capture defaults so reset() can restore them.
    this.defaultPinning = {
      left: [...(pinningState.left ?? [])],
      right: [...(pinningState.right ?? [])],
    };
    this.defaultVisibility = { ...visibilityState };
    this.defaultOrder =
      orderState.length > 0 ? [...orderState] : table.getAllColumns().map((c) => c.id);

    // Restore from localStorage if a persisted entry exists.
    const persisted = this.readStorage();
    if (persisted) {
      table.setColumnPinning(persisted.columnPinning);
      table.setColumnVisibility(persisted.columnVisibility);
      table.setColumnOrder(persisted.columnOrder);
    }

    this.initialized = true;
    this.rebuildItems(table, labelMap);
  }

  private rebuildItems(table: Table<T>, labelMap: Map<string, string>): void {
    const items: ColumnToggleItem[] = table.getAllColumns().map((column) => ({
      key: column.id,
      label: labelMap.get(column.id) ?? column.id,
      visible: column.getIsVisible(),
      pinned: column.getIsPinned() === 'left',
      canHide: column.getCanHide(),
      canUnpin: !this.declaredStickyIds.has(column.id),
    }));
    // Sort by current column order so drag positions match table render.
    const order = table.getState().columnOrder;
    if (order.length > 0) {
      const indexOf = new Map(order.map((id, i) => [id, i]));
      items.sort((a, b) => (indexOf.get(a.key) ?? Infinity) - (indexOf.get(b.key) ?? Infinity));
    }
    this.toggleItems.set(items);
  }

  private canUnpinId(id: string): boolean {
    return !this.declaredStickyIds.has(id);
  }

  // ─── persistence ────────────────────────────────────────────

  private storageKey(): string {
    return `${STORAGE_KEY_PREFIX}:${this.router.url}:${this.tableId()}`;
  }

  private readStorage(): PersistedTableState | null {
    if (typeof localStorage === 'undefined') return null;
    try {
      const raw = localStorage.getItem(this.storageKey());
      if (!raw) return null;
      const parsed = JSON.parse(raw) as PersistedTableState;
      if (!parsed.columnPinning || !parsed.columnVisibility || !parsed.columnOrder) {
        return null;
      }
      return parsed;
    } catch {
      return null;
    }
  }

  private persist(table: Table<T>): void {
    if (typeof localStorage === 'undefined') return;
    try {
      const state = table.getState();
      const persisted: PersistedTableState = {
        columnPinning: state.columnPinning,
        columnVisibility: state.columnVisibility,
        columnOrder: state.columnOrder,
      };
      localStorage.setItem(this.storageKey(), JSON.stringify(persisted));
    } catch {
      // ignore quota / serialization errors silently
    }
  }

  private removeStorage(): void {
    if (typeof localStorage === 'undefined') return;
    try {
      localStorage.removeItem(this.storageKey());
    } catch {
      // ignore
    }
  }
}
