import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
  LayoutEntry,
  LayoutPersistence,
  LayoutSpec,
  TileSpan,
  TileSpec,
} from './types.js';
import { localStoragePersistence } from './persistence.js';

export interface UseWorkspaceLayoutOptions {
  /** Persistence key (route-scoped). Should be product + view unique. */
  storageKey: string;
  /** Override the persistence layer — default is `localStorage`. */
  persistence?: LayoutPersistence;
}

export interface UseWorkspaceLayoutHelpers {
  layout: LayoutSpec;
  /** Move a tile from `fromIndex` → `toIndex` (clamped). */
  move: (fromIndex: number, toIndex: number) => void;
  /** Set a tile's span. */
  resize: (id: string, span: TileSpan) => void;
  /** Reset to the default layout (one entry per tile, defaultSpan or 2). */
  reset: () => void;
  /** True once the persistence load has resolved. */
  hydrated: boolean;
}

/**
 * `useWorkspaceLayout` — derives the live layout from a tile registry +
 * persistence layer. Order + spans are persisted on every change.
 *
 * The hook is `tiles`-driven: pass the canonical tile registry and the
 * hook returns the current ordered+sized layout. Tiles absent from
 * persisted layout are appended in registry order; persisted entries
 * referencing removed tiles are dropped (defensive against schema
 * drift between releases).
 */
export function useWorkspaceLayout(
  tiles: TileSpec[],
  options: UseWorkspaceLayoutOptions,
): UseWorkspaceLayoutHelpers {
  const persistence = options.persistence ?? localStoragePersistence;
  const persistenceRef = useRef(persistence);
  persistenceRef.current = persistence;

  const defaults: LayoutSpec = useMemo(
    () => ({
      entries: tiles.map((t) => ({ id: t.id, span: t.defaultSpan ?? 2 })),
    }),
    [tiles],
  );

  const [layout, setLayout] = useState<LayoutSpec>(defaults);
  const [hydrated, setHydrated] = useState(false);

  // One-shot load on mount.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const loaded = await persistenceRef.current.load(options.storageKey);
        if (cancelled) return;
        if (loaded) {
          setLayout(reconcile(loaded, tiles));
        }
      } finally {
        if (!cancelled) setHydrated(true);
      }
    })();
    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [options.storageKey]);

  // Re-reconcile whenever the tile registry changes (add/remove/reorder).
  // Short-circuit identical layouts to avoid setLayout → save effect →
  // re-render loop when callers forget to memoise `tiles`.
  useEffect(() => {
    if (!hydrated) return;
    setLayout((cur) => {
      const next = reconcile(cur, tiles);
      return sameLayout(cur, next) ? cur : next;
    });
  }, [tiles, hydrated]);

  // Persist on every change (after hydration so initial load doesn't write back).
  useEffect(() => {
    if (!hydrated) return;
    void persistenceRef.current.save(options.storageKey, layout);
  }, [layout, options.storageKey, hydrated]);

  const move = useCallback((from: number, to: number) => {
    setLayout((cur) => {
      const len = cur.entries.length;
      if (from === to || from < 0 || from >= len) return cur;
      const clampedTo = Math.max(0, Math.min(len - 1, to));
      const next = cur.entries.slice();
      const [moved] = next.splice(from, 1);
      if (!moved) return cur;
      next.splice(clampedTo, 0, moved);
      return { entries: next };
    });
  }, []);

  const resize = useCallback((id: string, span: TileSpan) => {
    setLayout((cur) => ({
      entries: cur.entries.map((e) => (e.id === id ? { ...e, span } : e)),
    }));
  }, []);

  const reset = useCallback(() => {
    setLayout(defaults);
  }, [defaults]);

  return { layout, move, resize, reset, hydrated };
}

/**
 * Drop entries that no longer exist; append new tiles in registry order
 * with their default span. Pure function (used by tests).
 *
 * Also sanitises spans — a corrupt persisted entry with span=NaN /
 * 0 / negative / 100 used to render "grid-column: span NaN" which
 * silently broke the layout; we now clamp to `[1, 4]`.
 */
export function reconcile(
  layout: LayoutSpec,
  tiles: TileSpec[],
): LayoutSpec {
  const knownIds = new Set(tiles.map((t) => t.id));
  const surviving: LayoutEntry[] = layout.entries
    .filter((e) => knownIds.has(e.id))
    .map((e) => ({ id: e.id, span: sanitiseSpan(e.span) }));
  const seen = new Set(surviving.map((e) => e.id));
  for (const t of tiles) {
    if (!seen.has(t.id)) {
      surviving.push({ id: t.id, span: sanitiseSpan(t.defaultSpan ?? 2) });
    }
  }
  return { entries: surviving };
}

function sanitiseSpan(span: unknown): TileSpan {
  const n = typeof span === 'number' && Number.isFinite(span) ? span : 2;
  return Math.max(1, Math.min(4, Math.round(n))) as TileSpan;
}

/** Cheap structural equality between two layouts. */
function sameLayout(a: LayoutSpec, b: LayoutSpec): boolean {
  if (a.entries.length !== b.entries.length) return false;
  for (let i = 0; i < a.entries.length; i++) {
    const ae = a.entries[i]!;
    const be = b.entries[i]!;
    if (ae.id !== be.id || ae.span !== be.span) return false;
  }
  return true;
}
