<script setup>
/**
 * B2CDataTable – generieke PrimeVue DataTable met:
 * - Kolomconfiguratie via columns (key, header, type, searchable, filterable, defaultVisible)
 * - Global search (debounced) + per-kolom filters (client-side, simpel: contains/equals)
 * - ColumnPicker: kolommen aan/uit + drag & drop volgorde + zoekveld
 * - State: server (persistToServer) of localStorage; save only when changed, debounced, last-write-wins
 *   (includes columnWidths px per key; PrimeVue resizable columns, fit mode)
 * - Loading, empty state, reset search+filters, reset kolommen (reset kolommen clears server via DELETE)
 *
 * @typedef {Object} ColumnDef
 * @property {string} key
 * @property {string} header
 * @property {string} [type='text'] - 'text' | 'number' | 'date' | 'badge'. For 'badge', use valueMap.
 * @property {string} [filterType] - Optional logical filter type: 'text' | 'number' | 'date' | 'boolean'. Defaults to type when omitted.
 * @property {Object.<string, {label: string, icon?: string, severity?: string, class?: string}>} [valueMap] - For type 'badge': raw value → { label, icon, class } or { label, icon, severity } (severity is mapped to our preset classes). All badges render as the same span style; use shared maps from /constants/badgeStyles.js.
 * @property {{ label: string, class: string }} [defaultBadgeEntry] - For type 'badge' when value is not in valueMap. When used without valueMap, the raw cell value is used as label and this entry provides the class (and fallback label when the value is empty).
 * @property {boolean} [hideBadgeWhenEmpty=false] - For type 'badge': when true and the raw value is empty/null, no badge is rendered (cell stays empty).
 * @property {string} [filterKey] - When set, filter uses this row key instead of column key (e.g. filter by display label).
 * @property {boolean} [searchable=true]
 * @property {boolean} [filterable=false]
 * @property {boolean} [defaultVisible=true]
 */

import ColumnPicker from "./ColumnPicker.vue";
import B2CDataTableFilterPanel from "./B2CDataTableFilterPanel.vue";
import BaseSearch from "../base/BaseSearch.vue";
import { useTableStatePersistence } from "../../composables/useTableStatePersistence.js";
import {
  useDataTableFilters,
  getFilterScalar,
  getFilterOp,
  getFilterTypeForColumn,
  TEXT_LIKE_TYPES,
} from "../../composables/useDataTableFilters.js";
import {
  exportDataTable,
  getDataTableExportStatus,
} from "../../services/dataTableService.js";
import { useBaseToast } from "../../composables/useBaseToast.js";
import BaseAlert from "../base/BaseAlert.vue";

import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  watch,
} from "vue";
import {
  BADGE_BASE_CLASSES,
  DEFAULT_BADGE_ENTRY,
  SEVERITY_TO_BADGE_CLASS,
} from "../../constants/badgeStyles.js";

const STORAGE_PREFIX = "b2c-datatable";
const SEARCH_DEBOUNCE_MS = 400;
const EMPTY_MESSAGE = "Geen resultaten";
const EXPORT_STATUS_POLL_INTERVAL_MS = 5000;
const EXPORT_STATUS_MAX_ATTEMPTS = 10;
/** User-resizable column width bounds (px), persisted in table state. */
const MIN_COL_WIDTH_PX = 48;
const MAX_COL_WIDTH_PX = 800;

function useDebounce(getValue, setValue, ms) {
  let timeout = null;
  return {
    flush() {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
        setValue(getValue());
      }
    },
    schedule() {
      if (timeout) clearTimeout(timeout);
      timeout = setTimeout(() => {
        timeout = null;
        setValue(getValue());
      }, ms);
    },
  };
}

const props = defineProps({
  tableKey: { type: String, required: true }, // Unieke sleutel voor localStorage (bijv. 'companies.index')
  columns: { type: Array, default: () => [] }, // Kolomdefinities
  rows: { type: Array, default: () => [] }, // Rijen (worden client-side gefilterd)
  loading: { type: Boolean, default: false }, // Loading state: toon DataTable loading indicator
  paginator: { type: Boolean, default: true }, // Toon paginator (stap 1 vaak false)
  rowsPerPage: { type: Number, default: 10 }, // Aantal rijen per pagina (paginator)
  rowsPerPageOptions: { type: Array, default: () => [10, 25, 50] }, // Rows per page opties
  persistToServer: { type: Boolean, default: true }, // Persist state to server (GET/PUT/DELETE). If false, localStorage only
  serverMode: { type: Boolean, default: false }, // Server-driven: no client pagination/sort; emit update:pagination, update:sort
  totalRecords: { type: Number, default: 0 }, // Total row count (server, filtered). For paginator when serverMode=true
  totalAllRecords: { type: Number, default: 0 }, // Unfiltered total (server). For "X van Y" when filters/search active
  pagination: { type: Object, default: () => ({ page: 1, limit: 10 }) }, // Controlled pagination when serverMode=true
  sort: { type: Array, default: () => [] }, // Controlled sort when serverMode=true. [{ field, order: 1|-1 }]
  rowClickable: { type: Boolean, default: false }, // When true, rows clickable; emit row-click; cursor pointer
  showActions: { type: Boolean, default: false }, // When true, extra Actions column (last) with optional #actions slot
  tableError: { type: String, default: null }, // Optional error from DataTable fetch; shown in filter panel
  resourceKey: { type: String, default: null }, // Backend resource key for export (e.g. "companies"). Required when enableExport
  resourceLabelSingular: { type: String, default: "resultaat" }, // Label for count 1 (NL default).
  resourceLabelPlural: { type: String, default: "resultaten" }, // Label for count !== 1 (NL default).
  enableExport: { type: Boolean, default: false }, // When true, Export button + CSV via POST /api/datatables/{resourceKey}/export
  /**
   * Optional extra filters that are always applied for export (and cannot be removed
   * via the UI). Used for scoped views such as company contacts, where the export
   * must always include a fixed company_id constraint in addition to any user
   * filters. Shape matches columnFilters: { [columnKey]: { value, op? } }.
   */
  exportFilters: { type: Object, default: () => ({}) },
  infiniteScroll: { type: Boolean, default: false }, // When true with serverMode: no paginator; scrollable body; emit load-more
  /** Height of the scrollable body when infiniteScroll + not fillHeight. Avoid `100%` (parent chain often has no height). With fillHeight, height comes from flex; this prop is ignored. */
  scrollHeight: { type: String, default: "60vh" },
  title: { type: String, default: "" }, // Unused; header lives in BaseIndex. Kept for backward compat.
  /** With infiniteScroll: cap root at viewport minus chrome (toolbar/breadcrumb) and let the table body flex-1 + overflow scroll. */
  fillHeight: { type: Boolean, default: false },
  /** Optional (rowData) => string for extra <tr> classes (PrimeVue passes row data). */
  rowClassFn: { type: Function, default: null },
  /** Toolbar search field placeholder (TLP default: Dutch). */
  searchPlaceholder: { type: String, default: "Zoeken..." },
});

const emit = defineEmits([
  "update:state",
  "update:pagination",
  "update:sort",
  "row-click",
  "export",
  "load-more",
]);

const storageKey = computed(() => `${STORAGE_PREFIX}-${props.tableKey}`);

const visibleColumnKeys = ref([]);
const orderedColumnKeys = ref([]);
/** @type {import('vue').Ref<Record<string, number>>} Per-column widths in px (keys match ColumnDef.key or 'actions'). */
const columnWidths = ref({});
/** Waarde gebruikt voor filtering + persistentie */
const globalSearch = ref("");
/** Gebonden aan search input; debounced naar globalSearch */
const searchInput = ref("");
const columnsPanelRef = ref(null);
const isFilterPanelOpen = ref(false);
const exporting = ref(false);
const toast = useBaseToast();
const sentinelRef = ref(null);
const scrollWrapperRef = ref(null);
let loadMoreObserver = null;

/** Kolommen met `pinnedFirst` blijven vooraan en kunnen niet van positie wisselen (kolomkiezer). */
const pinnedFirstKeys = computed(() =>
  (props.columns || []).filter((c) => c.pinnedFirst).map((c) => c.key),
);

function normalizeColumnOrder(keys) {
  const list = Array.isArray(keys) ? keys : [];
  const pinned = pinnedFirstKeys.value.filter((k) => list.includes(k));
  const rest = list.filter((k) => !pinned.includes(k));
  return [...pinned, ...rest];
}

const defaultOrder = computed(() =>
  normalizeColumnOrder((props.columns || []).map((c) => c.key)),
);
const defaultVisible = computed(() =>
  (props.columns || [])
    .filter((c) => c.defaultVisible !== false)
    .map((c) => c.key),
);

/** Fingerprint of deployment column defaults; when it changes, re-apply `defaultVisible` from props (see applyState). */
const columnDefaultsSig = computed(() =>
  (props.columns || [])
    .map((c) => `${c.key}:${c.defaultVisible === false ? 0 : 1}`)
    .join("|"),
);

const validColumnKeys = computed(() => (props.columns || []).map((c) => c.key));
const validColumnSet = computed(() => new Set(validColumnKeys.value));
/** Keys allowed in persisted columnWidths (data columns + optional actions column). */
const validWidthKeys = computed(() => {
  const keys = new Set(validColumnKeys.value);
  if (props.showActions) keys.add("actions");
  return keys;
});

/** Zichtbare kolommen in de volgorde van de gebruiker (voor tabel-render) */
const computedVisibleOrderedColumns = computed(() => {
  const visibleSet = new Set(visibleColumnKeys.value);
  const keyToCol = new Map((props.columns || []).map((c) => [c.key, c]));
  return orderedColumnKeys.value
    .filter((key) => visibleSet.has(key))
    .map((key) => keyToCol.get(key))
    .filter(Boolean);
});

// ---------------------------------------------------------------------------
// Filter composable
// ---------------------------------------------------------------------------

const {
  columnFilters,
  filterRows,
  filterableColumns,
  resolveRowOperator,
  buildFilterRowsFromColumnFilters,
  getAvailableFilterColumnsForRow,
  addFilterRow,
  removeFilterRow,
  setFilterRowColumnKey,
  setFilterRowValue,
  setFilterRowDateModel,
  setFilterRowOperator,
  clearFilters,
} = useDataTableFilters(
  computed(() => props.columns),
  computedVisibleOrderedColumns,
);

// ---------------------------------------------------------------------------
// State persistence helpers
// ---------------------------------------------------------------------------

/** Backend expects filters.*.value (object shape). We always store and emit { columnKey: { value: scalar, op?: string } }. */
function getState() {
  return {
    visibleColumns: [...visibleColumnKeys.value],
    columnOrder: [...orderedColumnKeys.value],
    globalSearch: globalSearch.value,
    filters: { ...columnFilters.value },
    columnWidths: { ...columnWidths.value },
    columnDefaultsSig: columnDefaultsSig.value,
  };
}

function applyState(state) {
  if (!state || typeof state !== "object") return;
  const validSet = validColumnSet.value;
  const allKeys = validColumnKeys.value;
  const curSig = columnDefaultsSig.value;
  const savedSig =
    typeof state.columnDefaultsSig === "string" ? state.columnDefaultsSig : "";

  // Deployment defaults changed (Superadmin, API merge, or first run with real columns): align visibility/order with props.
  if (curSig !== "" && savedSig !== curSig) {
    visibleColumnKeys.value = [...defaultVisible.value];
    orderedColumnKeys.value = normalizeColumnOrder([...defaultOrder.value]);
  } else {
    // Restore saved visible columns (only keys that still exist).
    const savedVisible = Array.isArray(state.visibleColumns)
      ? state.visibleColumns.filter((k) => validSet.has(k))
      : [];

    // Restore saved order (only keys that still exist).
    const savedOrder = Array.isArray(state.columnOrder)
      ? state.columnOrder.filter((k) => validSet.has(k))
      : [];

    // Detect brand-new columns that were not part of the saved state at all.
    const savedKnownKeys = new Set([...savedVisible, ...savedOrder]);
    const newKeys = allKeys.filter((k) => !savedKnownKeys.has(k));

    // Visible columns: previously visible + all new columns (so newly added columns show up by default).
    visibleColumnKeys.value = [...savedVisible, ...newKeys];

    // Order: previously saved order + new columns appended at the end.
    orderedColumnKeys.value = normalizeColumnOrder([...savedOrder, ...newKeys]);
  }

  if (typeof state.globalSearch === "string") {
    globalSearch.value = state.globalSearch;
    searchInput.value = state.globalSearch;
  }
  if (state.filters && typeof state.filters === "object") {
    const normalized = {};
    Object.entries(state.filters).forEach(([key, v]) => {
      if (!validSet.has(key)) return;
      const scalar = getFilterScalar(v);
      const op = getFilterOp(v);

      if (Array.isArray(scalar)) {
        const arr = scalar.map((s) => String(s ?? "").trim()).filter(Boolean);
        if (arr.length > 0) {
          normalized[key] = op ? { value: arr, op } : { value: arr };
        }
        return;
      }

      if (scalar !== "" && String(scalar).trim() !== "") {
        const trimmed = String(scalar).trim();
        normalized[key] = op ? { value: trimmed, op } : { value: trimmed };
      }
    });
    columnFilters.value = normalized;
  }

  if (state.columnWidths && typeof state.columnWidths === "object") {
    const next = {};
    for (const [k, v] of Object.entries(state.columnWidths)) {
      if (!validWidthKeys.value.has(k)) continue;
      const n = Number(v);
      if (!Number.isFinite(n)) continue;
      next[k] = Math.min(
        Math.max(Math.round(n), MIN_COL_WIDTH_PX),
        MAX_COL_WIDTH_PX,
      );
    }
    columnWidths.value = next;
  } else {
    columnWidths.value = {};
  }
  buildFilterRowsFromColumnFilters();
}

const canExport = computed(() =>
  Boolean(props.enableExport && props.resourceKey),
);

/** True when global search or any column filter has a non-empty value. */
const hasActiveCriteria = computed(() => {
  if ((globalSearch.value || "").trim().length > 0) return true;
  return Object.values(columnFilters.value).some((v) => {
    const scalar = getFilterScalar(v);
    if (Array.isArray(scalar)) return scalar.filter(Boolean).length > 0;
    return scalar != null && String(scalar).trim() !== "";
  });
});

/** "X van Y <label>" or "Y <label>" for serverMode; uses resourceLabelSingular / resourceLabelPlural (defaults: resultaat / resultaten). */
const resultLabel = computed(() => {
  if (!props.serverMode) return null;
  const filtered = props.totalRecords ?? 0;
  const all = props.totalAllRecords ?? 0;
  const singular = props.resourceLabelSingular.trim();
  const plural = props.resourceLabelPlural.trim();
  const labelFor = (count) => (count === 1 ? singular : plural);

  if (hasActiveCriteria.value) {
    if (all > 0) return `${filtered} van ${all} ${plural}`;
    return `${filtered} ${labelFor(filtered)}`;
  }
  if (all > 0) return `${all} ${labelFor(all)}`;
  return `${filtered} ${labelFor(filtered)}`;
});

// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------

function buildExportPayload() {
  const staticFilters =
    props.exportFilters && typeof props.exportFilters === "object"
      ? props.exportFilters
      : {};

  return {
    tableKey: props.tableKey,
    visibleColumns: [...visibleColumnKeys.value],
    columnOrder: [...orderedColumnKeys.value],
    // Merge user-configurable filters with static export filters. Static filters
    // win for overlapping keys so critical constraints (e.g. company_id) cannot
    // be removed from the export via the UI.
    filters: { ...columnFilters.value, ...staticFilters },
    globalSearch: (globalSearch.value || "").trim(),
    sort: props.serverMode && Array.isArray(props.sort) ? [...props.sort] : [],
  };
}

async function extractErrorMessage(err) {
  let message = err?.message ?? "Export mislukt";
  const data = err?.response?.data;
  if (data) {
    if (typeof data === "object" && data.message) {
      message = data.message;
    } else if (data instanceof Blob && data.type?.includes("json")) {
      try {
        const parsed = JSON.parse(await data.text());
        message = parsed?.message ?? message;
      } catch (_) {
        /* ignore */
      }
    }
  }
  return message;
}

async function pollSingleExportStatus(exportId, attempt = 0) {
  if (!exportId || attempt >= EXPORT_STATUS_MAX_ATTEMPTS) return;
  try {
    const status = await getDataTableExportStatus(exportId);
    if (status.status === "failed") {
      const label = status.resourceLabel || status.resourceKey || "tabel";

      const detail =
        status.errorMessage || "Export mislukt, probeer het later opnieuw.";
      toast.add({
        severity: "error",
        summary: `Export voor ${label} mislukt`,
        detail,
        life: 6000,
      });
      return;
    }
    if (status.status === "ready" || status.status === "completed") {
      // Success path: header will show ready exports; nothing to do here.
      // Success path: header will show ready exports; nothing to do here.
      return;
    }
  } catch (err) {
    // Swallow transient errors; try again until max attempts.
    // eslint-disable-next-line no-console
    console.warn("B2CDataTable: export status check failed", err);
  }
  setTimeout(() => {
    pollSingleExportStatus(exportId, attempt + 1);
  }, EXPORT_STATUS_POLL_INTERVAL_MS);
}

async function doExport() {
  if (!canExport.value) {
    if (props.enableExport && !props.resourceKey) {
      console.warn(
        "B2CDataTable: enableExport is true but resourceKey is missing. Export disabled.",
      );
    }
    return;
  }
  exporting.value = true;
  try {
    const payload = buildExportPayload();
    const result = await exportDataTable(props.resourceKey, payload);
    toast.add({
      severity: "info",
      summary: "Export wordt voorbereid",
      detail: "Je krijgt een melding in de header zodra het bestand klaar is.",
      life: 6000,
    });
    if (result?.id) {
      // Background monitor for this specific export to surface failures to the user.
      pollSingleExportStatus(result.id, 0);
    }
    emit("export");
  } catch (err) {
    const message = await extractErrorMessage(err);
    toast.add({
      severity: "error",
      summary: "Export mislukt",
      detail: message,
      life: 5000,
    });
  } finally {
    exporting.value = false;
  }
}

// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------

const persistence = useTableStatePersistence(props.tableKey, getState, {
  showToastOnError: true,
});

/**
 * When parent loads `columns` async, first mount can run with `columns=[]`.
 * Applying saved state then filters all keys out (validWidthKeys empty) and
 * scheduleSave() would persist empty columnWidths — wiping server data.
 * We defer applyState+init until column definitions exist.
 */
const pendingInitialState = ref(null);

const debounce = useDebounce(
  () => searchInput.value,
  (v) => {
    globalSearch.value = typeof v === "string" ? v : "";
  },
  SEARCH_DEBOUNCE_MS,
);

function readLocalStorageState() {
  try {
    const raw = localStorage.getItem(storageKey.value);
    if (!raw) return null;
    return JSON.parse(raw);
  } catch (_) {
    return null;
  }
}

function loadStateLocal() {
  const data = readLocalStorageState();
  if (data) applyState(data);
}

function initFromColumns() {
  if (orderedColumnKeys.value.length === 0) {
    orderedColumnKeys.value = [...defaultOrder.value];
  } else {
    orderedColumnKeys.value = normalizeColumnOrder(orderedColumnKeys.value);
  }
  if (visibleColumnKeys.value.length === 0) {
    visibleColumnKeys.value = [...defaultVisible.value];
  }
}

function saveStateLocal() {
  try {
    localStorage.setItem(storageKey.value, JSON.stringify(getState()));
  } catch (_) {
    // ignore quota etc.
  }
}

// ---------------------------------------------------------------------------
// Computed table helpers
// ---------------------------------------------------------------------------

/** Eén state-object voor filtering/kolommen (later eenvoudig te koppelen aan server) */
const tableState = computed(() => ({
  globalSearch: globalSearch.value,
  filters: { ...columnFilters.value },
  visibleColumns: [...visibleColumnKeys.value],
  columnOrder: [...orderedColumnKeys.value],
  columnWidths: { ...columnWidths.value },
}));

/** Searchable columns (for global search we use filterKey when set, so e.g. "Ingelogd" matches). */
const searchableColumns = computed(() =>
  computedVisibleOrderedColumns.value.filter((c) => c.searchable !== false),
);

function getCellValue(row, key) {
  const v = row[key];
  if (v == null) return "";
  if (typeof v === "object" && v instanceof Date) return v.toISOString();
  return String(v);
}

/**
 * For columns with type: 'badge', return { label, icon, class } so we always render the same span (one consistent style).
 * valueMap entries can use .class (Tailwind) or .severity (mapped to our preset classes). No PrimeVue Tag for badges.
 */
function getBadgeDisplay(row, col) {
  const raw = row[col.key];
  const key = raw == null ? "" : String(raw);
  const map = col.valueMap || {};
  const entry = map[key];
  const defaultEntry = col.defaultBadgeEntry || DEFAULT_BADGE_ENTRY;
  if (col.hideBadgeWhenEmpty && (key === "" || key.trim() === "")) {
    return { label: "", icon: null, class: null };
  }
  if (entry && entry.label != null) {
    const c =
      entry.class ??
      (entry.severity != null
        ? SEVERITY_TO_BADGE_CLASS[entry.severity]
        : null) ??
      SEVERITY_TO_BADGE_CLASS.secondary;
    return { label: entry.label, icon: entry.icon ?? null, class: c };
  }
  if (defaultEntry && defaultEntry.class)
    return {
      label: key || (defaultEntry.label ?? "—"),
      icon: defaultEntry.icon ?? null,
      class: defaultEntry.class,
    };
  return {
    label: key || "—",
    icon: null,
    class: SEVERITY_TO_BADGE_CLASS.secondary,
  };
}

// ---------------------------------------------------------------------------
// Client-side filtering (skipped in serverMode)
// ---------------------------------------------------------------------------

/** Filterlogica: case-insensitive global search + per-kolom operators (client-side). Skipped in serverMode (server handles filtering). */
const filteredRows = computed(() => {
  let list = props.rows || [];

  if (props.serverMode) return list;

  const g = (globalSearch.value || "").trim().toLowerCase();

  if (g) {
    list = list.filter((row) => {
      const combined = searchableColumns.value
        .map((c) => getCellValue(row, c.filterKey ?? c.key))
        .join(" ")
        .toLowerCase();
      return combined.includes(g);
    });
  }

  Object.entries(columnFilters.value).forEach(([key, value]) => {
    const raw = getFilterScalar(value);
    const scalar = String(raw).trim();
    if (!scalar) return;
    const col = (props.columns || []).find((c) => c.key === key);
    const filterKey = col?.filterKey ?? key;
    const op = resolveRowOperator(key, value);
    const filterType = col ? getFilterTypeForColumn(col) : "text";
    const isTextLike = TEXT_LIKE_TYPES.includes(filterType);
    list = list.filter((row) => {
      const cellRaw = getCellValue(row, filterKey);
      const cell = String(cellRaw ?? "").toLowerCase();
      const vLower = scalar.toLowerCase();

      if (isTextLike) {
        if (op === "contains") return cell.includes(vLower);
        if (op === "starts_with") return cell.startsWith(vLower);
        if (op === "ends_with") return cell.endsWith(vLower);
        if (op === "equals") return cell === vLower;
        if (op === "not_equals") return cell !== vLower;
        return cell.includes(vLower);
      }

      if (filterType === "number") {
        const num = Number(cellRaw);
        const filterNum = Number(scalar);
        if (Number.isNaN(num) || Number.isNaN(filterNum)) return false;
        if (op === "equals") return num === filterNum;
        if (op === "not_equals") return num !== filterNum;
        if (op === "lt") return num < filterNum;
        if (op === "lte") return num <= filterNum;
        if (op === "gt") return num > filterNum;
        if (op === "gte") return num >= filterNum;
        return num === filterNum;
      }

      if (filterType === "date") {
        const cellDate = new Date(cellRaw);
        if (Number.isNaN(cellDate.getTime())) return false;
        if (op === "date_equals") {
          return cell.startsWith(vLower);
        }
        if (op === "before") {
          const filterDate = new Date(scalar);
          if (Number.isNaN(filterDate.getTime())) return false;
          return cellDate < filterDate;
        }
        if (op === "after") {
          const filterDate = new Date(scalar);
          if (Number.isNaN(filterDate.getTime())) return false;
          return cellDate > filterDate;
        }
        if (op === "between") {
          const parts = scalar.split("|");
          if (parts.length !== 2) return false;
          const start = new Date(parts[0].trim());
          const end = new Date(parts[1].trim());
          if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()))
            return false;
          return cellDate >= start && cellDate <= end;
        }
        return cell.startsWith(vLower);
      }

      if (filterType === "boolean") {
        const boolCell =
          cellRaw === true ||
          cellRaw === "true" ||
          cellRaw === 1 ||
          cellRaw === "1";
        const boolFilter = scalar === "true" || scalar === "1";
        if (op === "equals") return boolCell === boolFilter;
        if (op === "not_equals") return boolCell !== boolFilter;
        return boolCell === boolFilter;
      }

      if (filterType === "select") {
        const eq = String(cellRaw ?? "") === scalar;
        if (op === "equals") return eq;
        if (op === "not_equals") return !eq;
        return eq;
      }

      return cell.includes(vLower);
    });
  });

  return list;
});

/** Filtered list. In server mode we still filter current rows client-side so column filters and search apply. */
const displayRows = computed(() => filteredRows.value);

// ---------------------------------------------------------------------------
// Pagination / sort / scroll
// ---------------------------------------------------------------------------

/** Paginator total: server total or client filtered count */
const paginatorTotal = computed(() =>
  props.serverMode ? props.totalRecords : filteredRows.value.length,
);

/** Lazy paginator first row index (0-based) */
const lazyFirst = computed(() => {
  if (!props.serverMode) return 0;
  const p = props.pagination || { page: 1, limit: 10 };
  return (Math.max(1, p.page) - 1) * Math.max(1, p.limit);
});

/** Rows per page: from controlled pagination in server mode, else prop */
const lazyRows = computed(() =>
  props.serverMode
    ? Math.max(1, (props.pagination || {}).limit || 10)
    : props.rowsPerPage,
);

/** When infiniteScroll is true, hide paginator. */
const computedPaginator = computed(
  () => !props.infiniteScroll && props.paginator,
);

/** When fillHeight + infinite: no fixed height — flex-1 fills inside max-h root. Else fixed height (e.g. 60vh). */
const scrollWrapperStyle = computed(() => {
  if (props.infiniteScroll && props.fillHeight) {
    return {};
  }
  if (props.infiniteScroll && props.serverMode) {
    return { height: props.scrollHeight };
  }
  return {};
});

const infiniteScrollWrapperClass = computed(() =>
  props.fillHeight
    ? "b2c-infinite-scroll-wrapper overflow-y-auto overflow-x-auto flex-1 min-h-0 w-full"
    : "b2c-infinite-scroll-wrapper overflow-y-auto overflow-x-auto min-h-0 w-full",
);

/** First sort for PrimeVue (field + order) when serverMode */
const sortField = computed(() =>
  props.serverMode && props.sort?.length ? props.sort[0].field : null,
);
const sortOrder = computed(() =>
  props.serverMode && props.sort?.length ? props.sort[0].order : null,
);

function onPage(event) {
  if (!props.serverMode || props.infiniteScroll) return;
  emit("update:pagination", {
    page: event.page + 1,
    limit: event.rows,
  });
}

function tryEmitLoadMore() {
  if (!props.serverMode || !props.infiniteScroll) return;
  if (props.loading) return;
  const rows = props.rows || [];
  const total = props.totalRecords ?? 0;
  if (rows.length >= total) return;
  emit("load-more");
}

function onSort(event) {
  if (!props.serverMode) return;
  const field = event.sortField ?? null;
  const order = event.sortOrder ?? null;
  if (field != null && order != null) {
    emit("update:sort", [{ field, order }]);
  }
}

function onRowClick(e) {
  if (!props.rowClickable) return;
  emit("row-click", e.data);
}

// PrimeVue passes row data into rowClass when it is a function.
function rowClass(rowData) {
  const parts = [];
  if (props.rowClickable) {
    parts.push("b2c-row-clickable");
  }
  if (typeof props.rowClassFn === "function") {
    const extra = props.rowClassFn(rowData);
    if (extra) parts.push(extra);
  }
  return parts.join(" ");
}

/** Column pass-through: mark header + body cells with column key (styling + resize persistence). */
function columnPt(col) {
  return {
    bodyCell: {
      "data-b2c-col-key": col.key,
    },
    headerCell: {
      "data-b2c-col-key": col.key,
    },
  };
}

/** Inline styles for a column: saved px width or defaults (data columns vs actions). */
function columnStyleForKey(key) {
  const w = columnWidths.value[key];
  if (w != null && Number.isFinite(w)) {
    /* Do not set minWidth/maxWidth to the same px as width — that locks the column and blocks narrowing on the next resize (PrimeVue applies new width !important; inline min-width would win up to the old width). Floor only. */
    return {
      width: `${w}px`,
      minWidth: `${MIN_COL_WIDTH_PX}px`,
    };
  }
  if (key === "actions") {
    return { width: "1%", whiteSpace: "nowrap", minWidth: "auto" };
  }
  /* Default min-width lives on class `b2c-datatable-col-default` so PrimeVue resize !important width is not blocked by inline min-width. */
  return {};
}

/** When no saved width: apply default min-width via CSS (see B2CDataTable.scss), not inline. */
function columnDefaultMinClass(key) {
  if (key === "actions") return undefined;
  const w = columnWidths.value[key];
  if (w != null && Number.isFinite(w)) return undefined;
  return "b2c-datatable-col-default";
}

/** After PrimeVue resize: persist all visible header widths by key. */
function onColumnResizeEnd(event) {
  const el = event?.element;
  if (!el || !(el instanceof HTMLElement)) return;
  const table = el.closest("table");
  if (!table) return;
  const headers = table.querySelectorAll("thead th[data-b2c-col-key]");
  if (!headers.length) return;
  const next = { ...columnWidths.value };
  headers.forEach((h) => {
    const key = h.getAttribute("data-b2c-col-key");
    if (!key || !validWidthKeys.value.has(key)) return;
    const w = Math.round(h.offsetWidth);
    next[key] = Math.min(Math.max(w, MIN_COL_WIDTH_PX), MAX_COL_WIDTH_PX);
  });
  columnWidths.value = next;
}

// ---------------------------------------------------------------------------
// Column picker
// ---------------------------------------------------------------------------

function toggleColumnsPanel(event) {
  columnsPanelRef.value?.toggle(event);
}

function setVisibleKeys(keys) {
  visibleColumnKeys.value = Array.isArray(keys) ? keys : [];
}

function setOrderedKeys(keys) {
  orderedColumnKeys.value = normalizeColumnOrder(
    Array.isArray(keys) ? keys : [],
  );
}

// ---------------------------------------------------------------------------
// Reset helpers
// ---------------------------------------------------------------------------

/** Reset global search en alle kolomfilters (also clears filter panel rows) */
function resetSearchAndFilters() {
  debounce.flush();
  searchInput.value = "";
  globalSearch.value = "";
  clearFilters();
}

/** Reset zichtbare kolommen en volgorde naar default; clears server state when persistToServer. */
async function resetColumns() {
  if (props.persistToServer) {
    await persistence.resetServerState();
  }
  visibleColumnKeys.value = [...defaultVisible.value];
  orderedColumnKeys.value = [...defaultOrder.value];
  columnWidths.value = {};
}

// ---------------------------------------------------------------------------
// Infinite scroll observer
// ---------------------------------------------------------------------------

function setupLoadMoreObserver() {
  if (loadMoreObserver) {
    loadMoreObserver.disconnect();
    loadMoreObserver = null;
  }
  if (
    !props.infiniteScroll ||
    !props.serverMode ||
    !sentinelRef.value ||
    !scrollWrapperRef.value
  )
    return;
  const root = scrollWrapperRef.value;
  loadMoreObserver = new IntersectionObserver(
    (entries) => {
      if (entries[0]?.isIntersecting) tryEmitLoadMore();
    },
    { root, rootMargin: "200px", threshold: 0 },
  );
  loadMoreObserver.observe(sentinelRef.value);
}

// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------

function hasColumnDefinitions() {
  return (props.columns || []).length > 0;
}

function hydrateFromInitialState(state) {
  applyState(state);
  initFromColumns();
  if (props.persistToServer) {
    persistence.scheduleSave();
  } else {
    saveStateLocal();
  }
}

onMounted(async () => {
  if (props.persistToServer) {
    const serverState = await persistence.load();
    if (!hasColumnDefinitions()) {
      pendingInitialState.value = { state: serverState };
    } else {
      hydrateFromInitialState(serverState);
    }
  } else {
    const local = readLocalStorageState();
    if (!hasColumnDefinitions()) {
      pendingInitialState.value = { state: local };
    } else {
      hydrateFromInitialState(local);
    }
  }
  await nextTick();
  emit("update:state", getState());
  if (props.infiniteScroll && props.serverMode) {
    nextTick(setupLoadMoreObserver);
  }
});

onBeforeUnmount(() => {
  if (props.persistToServer && !pendingInitialState.value) {
    // Flush pending debounced save so recent resize/state changes survive fast route switches.
    // Skip while initial state is deferred (columns not loaded yet) — otherwise we'd PUT empty widths.
    void persistence.flushSave();
  }
  if (loadMoreObserver) {
    loadMoreObserver.disconnect();
    loadMoreObserver = null;
  }
});

watch(
  () => [
    props.infiniteScroll,
    props.serverMode,
    sentinelRef.value,
    scrollWrapperRef.value,
  ],
  () => {
    if (props.infiniteScroll && props.serverMode)
      nextTick(setupLoadMoreObserver);
  },
  { flush: "post" },
);

// When rows change while the sentinel is already in view (common with collapsed grouped views),
// IntersectionObserver may not fire again. Re-check once after data updates.
watch(
  () => [
    props.infiniteScroll,
    props.serverMode,
    props.loading,
    props.rows?.length,
    props.totalRecords,
  ],
  () => {
    if (!props.infiniteScroll || !props.serverMode || props.loading) return;
    nextTick(() => {
      tryEmitLoadMore();
    });
  },
  { flush: "post" },
);

watch(searchInput, () => debounce.schedule());

watch(
  () => validColumnKeys.value.length,
  (len) => {
    if (len === 0 || !pendingInitialState.value) return;
    const payload = pendingInitialState.value;
    pendingInitialState.value = null;
    hydrateFromInitialState(payload.state);
    emit("update:state", getState());
  },
  { flush: "post" },
);

watch(
  [
    visibleColumnKeys,
    orderedColumnKeys,
    globalSearch,
    columnFilters,
    columnWidths,
  ],
  () => {
    if (props.persistToServer) {
      persistence.scheduleSave();
    } else {
      saveStateLocal();
    }
    emit("update:state", getState());
  },
  { deep: true },
);
</script>

<template>
  <div
    class="b2c-datatable"
    :class="{
      'min-h-0 flex flex-col max-h-[calc(100vh-12rem)] [&>*]:min-h-0':
        fillHeight,
    }"
  >
    <!-- Table toolbar: optional page title + search + actions -->
    <div
      class="flex flex-col gap-3 mb-0 w-full shrink-0 rounded-t-xl border border-b-0 border-surface-200 dark:border-surface-600 bg-surface-100 dark:bg-surface-800 p-4 sm:p-5"
    >
      <div
        v-if="$slots['toolbar-title'] || $slots['toolbar-title-right']"
        class="w-full min-w-0 shrink-0 pb-3 flex flex-wrap items-center gap-3 [&_h1]:!mb-0 [&_h2]:!mb-0 [&_h3]:!mb-0 [&_h4]:!mb-0"
      >
        <div class="min-w-0">
          <slot name="toolbar-title" />
        </div>
        <div
          v-if="$slots['toolbar-title-right']"
          class="ml-auto flex shrink-0 items-center gap-2 flex-wrap justify-end"
        >
          <slot name="toolbar-title-right" />
        </div>
      </div>
      <div class="flex items-center gap-2 flex-wrap w-full">
        <div
          class="flex items-center gap-2 flex-1 min-w-0 [&>*:first-child]:shrink-0 [&_.p-button]:whitespace-nowrap [&_.p-button]:px-[1.1rem] [&_.p-button]:py-0 [&_.p-button]:h-10 [&_.p-button_.p-button-label]:whitespace-nowrap"
        >
          <slot name="search-before">
            <slot name="controls-left" />
          </slot>
          <BaseSearch
            v-model="searchInput"
            :placeholder="searchPlaceholder"
            class="h-10 w-full min-w-0 flex-1 basis-80 max-w-[30rem] [&_.p-inputtext]:h-10 [&_.p-inputtext]:py-0 [&_.p-inputtext]:leading-10 [&_.p-inputtext]:ps-[2.2rem] [&_.p-inputicon]:!top-1/2 [&_.p-inputicon]:!mt-0 [&_.p-inputicon]:-translate-y-1/2 [&_.p-inputicon]:start-3 [&_.p-inputicon]:inline-flex [&_.p-inputicon]:items-center [&_.p-inputicon]:justify-center [&_.p-inputicon]:leading-none [&_.p-inputicon_i]:leading-none"
          />
        </div>
        <div
          class="flex items-center gap-2 ml-auto justify-end flex-wrap md:flex-nowrap"
        >
          <span
            v-if="resultLabel"
            class="inline-flex h-10 items-center leading-none text-sm text-surface-500 dark:text-surface-400 whitespace-nowrap"
          >
            {{ resultLabel }}
          </span>
          <span
            v-if="hasActiveCriteria"
            class="inline-flex h-10 items-center leading-none text-xs sm:text-sm text-surface-500 dark:text-surface-400 whitespace-nowrap"
          >
            Er zijn filters actief voor deze tabel
          </span>
          <slot name="controls-right" />
          <slot name="toolbar-icon-actions" />
          <Button
            icon="pi pi-list"
            rounded
            outlined
            severity="primary"
            size="large"
            class="!h-10 !w-10 !p-0 [&_.p-button-icon]:text-base shrink-0"
            aria-label="Kolommen"
            title="Kolommen"
            @click="toggleColumnsPanel($event)"
          />
          <Button
            icon="pi pi-filter"
            rounded
            :outlined="!hasActiveCriteria"
            severity="primary"
            size="large"
            class="!h-10 !w-10 !p-0 [&_.p-button-icon]:text-base shrink-0"
            aria-label="Filters"
            title="Filters"
            @click="isFilterPanelOpen = true"
          />
          <Button
            v-if="enableExport"
            icon="pi pi-download"
            rounded
            outlined
            severity="primary"
            size="large"
            class="!h-10 !w-10 !p-0 [&_.p-button-icon]:text-base shrink-0"
            aria-label="Export"
            title="Export"
            :loading="exporting"
            :disabled="!canExport || exporting"
            @click="doExport"
          />
          <Popover ref="columnsPanelRef" append-to="body">
            <div class="p-3">
              <ColumnPicker
                :all-columns="columns"
                :visible-keys="visibleColumnKeys"
                :ordered-keys="orderedColumnKeys"
                :pinned-keys="pinnedFirstKeys"
                @update:visible-keys="setVisibleKeys"
                @update:ordered-keys="setOrderedKeys"
              />
            </div>
          </Popover>
        </div>
      </div>
    </div>

    <div
      class="mb-3 w-full shrink-0 overflow-hidden rounded-b-xl border border-surface-200 dark:border-surface-600 bg-surface-0 dark:bg-surface-950 [&_.p-datatable-paginator-bottom]:!border-b-0 [&_.p-datatable-paginator-bottom_.p-select]:!shadow-none [&_.p-datatable-paginator-bottom_.p-select.p-focus]:!shadow-none"
    >
    <BaseAlert
      v-if="tableError"
      severity="error"
      :closable="false"
      class="mx-4 mt-4 mb-0 sm:mx-5"
    >
      {{ tableError }}
    </BaseAlert>

    <div
      v-if="infiniteScroll && serverMode"
      ref="scrollWrapperRef"
      :class="infiniteScrollWrapperClass"
      :style="scrollWrapperStyle"
    >
      <DataTable
        :value="displayRows"
        :loading="loading && displayRows.length === 0"
        :paginator="false"
        :lazy="serverMode"
        :sort-field="sortField"
        :sort-order="sortOrder"
        :row-class="rowClass"
        filter-display="menu"
        data-key="id"
        responsive-layout="scroll"
        resizable-columns
        column-resize-mode="expand"
        class="b2c-datatable-body b2c-datatable-infinite"
        @sort="onSort"
        @row-click="onRowClick"
        @column-resize-end="onColumnResizeEnd"
      >
        <Column
          v-for="col in computedVisibleOrderedColumns"
          :key="col.key"
          :field="col.key"
          :header="col.header"
          :sortable="col.key === 'actions' ? false : col.sortable !== false"
          :header-class="columnDefaultMinClass(col.key)"
          :body-class="columnDefaultMinClass(col.key)"
          :style="columnStyleForKey(col.key)"
          :pt="columnPt(col)"
        >
          <template #body="{ data }">
            <slot
              v-if="$slots[`cell-${col.key}`]"
              :name="`cell-${col.key}`"
              :row="data"
              :value="data[col.key]"
              :column="col"
            />
            <slot
              v-else-if="col.key === 'actions'"
              name="row-actions"
              :row="data"
            />
            <span
              v-else-if="
                col.type === 'badge' && getBadgeDisplay(data, col).class
              "
              :class="[BADGE_BASE_CLASSES, getBadgeDisplay(data, col).class]"
              ><i
                v-if="getBadgeDisplay(data, col).icon"
                :class="[getBadgeDisplay(data, col).icon, 'mr-2']"
              />
              {{ getBadgeDisplay(data, col).label }}</span
            >
            <span v-else>{{ getCellValue(data, col.key) }}</span>
          </template>
        </Column>
        <Column
          v-if="showActions"
          header=""
          :style="columnStyleForKey('actions')"
          :exportable="false"
          :sortable="false"
          :pt="columnPt({ key: 'actions' })"
        >
          <template #body="{ data }">
            <slot name="actions" :row="data" />
          </template>
        </Column>
        <template #empty>
          <div class="flex align-items-center justify-content-center py-6">
            {{ EMPTY_MESSAGE }}
          </div>
        </template>
      </DataTable>
      <div
        ref="sentinelRef"
        class="h-4 shrink-0 pointer-events-none"
        aria-hidden="true"
      />
      <div
        v-if="loading && displayRows.length > 0"
        class="flex align-items-center justify-content-center py-3 text-sm text-surface-500"
      >
        <i class="pi pi-spin pi-spinner mr-2" />
        Laden…
      </div>
    </div>
    <DataTable
      v-else
      :value="displayRows"
      :loading="loading"
      :paginator="computedPaginator"
      :rows="lazyRows"
      :first="serverMode ? lazyFirst : undefined"
      :total-records="paginatorTotal"
      :lazy="serverMode"
      :sort-field="sortField"
      :sort-order="sortOrder"
      :rows-per-page-options="rowsPerPageOptions"
      :row-class="rowClass"
      filter-display="menu"
      data-key="id"
      paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
      current-page-report-template="{first} tot {last} van {totalRecords}"
      responsive-layout="scroll"
      resizable-columns
      column-resize-mode="expand"
      class="b2c-datatable-body"
      @page="onPage"
      @sort="onSort"
      @row-click="onRowClick"
      @column-resize-end="onColumnResizeEnd"
    >
      <Column
        v-for="col in computedVisibleOrderedColumns"
        :key="col.key"
        :field="col.key"
        :header="col.header"
        :sortable="col.key === 'actions' ? false : col.sortable !== false"
        :header-class="columnDefaultMinClass(col.key)"
        :body-class="columnDefaultMinClass(col.key)"
        :style="columnStyleForKey(col.key)"
        :pt="columnPt(col)"
      >
        <template #body="{ data }">
          <slot
            v-if="$slots[`cell-${col.key}`]"
            :name="`cell-${col.key}`"
            :row="data"
            :value="data[col.key]"
            :column="col"
          />
          <slot
            v-else-if="col.key === 'actions'"
            name="row-actions"
            :row="data"
          />
          <span
            v-else-if="col.type === 'badge' && getBadgeDisplay(data, col).class"
            :class="[BADGE_BASE_CLASSES, getBadgeDisplay(data, col).class]"
            ><i
              v-if="getBadgeDisplay(data, col).icon"
              :class="[getBadgeDisplay(data, col).icon, 'mr-2']"
            />
            {{ getBadgeDisplay(data, col).label }}</span
          >
          <span v-else>{{ getCellValue(data, col.key) }}</span>
        </template>
      </Column>
      <Column
        v-if="showActions"
        header=""
        :style="columnStyleForKey('actions')"
        :exportable="false"
        :sortable="false"
        :pt="columnPt({ key: 'actions' })"
      >
        <template #body="{ data }">
          <slot name="actions" :row="data" />
        </template>
      </Column>
      <template #empty>
        <div class="flex align-items-center justify-content-center py-6">
          {{ EMPTY_MESSAGE }}
        </div>
      </template>
    </DataTable>
    </div>

    <B2CDataTableFilterPanel
      v-model:visible="isFilterPanelOpen"
      :columns="columns"
      :filter-rows="filterRows"
      :filterable-columns="filterableColumns"
      :table-error="tableError"
      :get-available-columns-for-row="getAvailableFilterColumnsForRow"
      @add-filter="addFilterRow"
      @remove-filter="removeFilterRow"
      @update:column-key="setFilterRowColumnKey"
      @update:operator="setFilterRowOperator"
      @update:value="setFilterRowValue"
      @update:date-model="setFilterRowDateModel"
      @reset="resetSearchAndFilters"
    />
  </div>
</template>
