import Papa from "papaparse";
import { parseDate } from "./dateParser";
import { parseFrenchAmount } from "./amountParser";
import type { ColumnMapping, AmountMode, SignConvention } from "../shared/types";

export interface AutoDetectResult {
  delimiter: string;
  hasHeader: boolean;
  skipLines: number;
  dateFormat: string;
  columnMapping: ColumnMapping;
  amountMode: AmountMode;
  signConvention: SignConvention;
}

const DATE_FORMATS = [
  "DD/MM/YYYY",
  "MM/DD/YYYY",
  "YYYY-MM-DD",
  "YYYY/MM/DD",
  "DD-MM-YYYY",
  "DD.MM.YYYY",
  "YYYYMMDD",
];

const DELIMITERS = [",", ";", "\t"];

/**
 * Detect and unwrap Desjardins-style CSVs where each entire line is
 * wrapped in quotes with "" escaping inside.
 */
export function preprocessQuotedCSV(content: string): string {
  const lines = content.split(/\r?\n/);
  const nonEmpty = lines.filter((l) => l.trim());
  if (nonEmpty.length === 0) return content;

  const isLineQuoted = nonEmpty.every((l) => {
    const t = l.trim();
    return t.startsWith('"') && t.endsWith('"') && t.includes(',""');
  });

  if (!isLineQuoted) return content;

  return lines
    .map((l) => {
      const t = l.trim();
      if (!t) return "";
      return t.slice(1, -1).replace(/""/g, '"');
    })
    .join("\n");
}

/**
 * Analyze raw CSV content and return a suggested configuration,
 * or null if detection fails.
 */
export function autoDetectConfig(rawContent: string): AutoDetectResult | null {
  const content = preprocessQuotedCSV(rawContent);
  const lines = content.split(/\r?\n/).filter((l) => l.trim());
  if (lines.length < 2) return null;

  // Step 1: Detect delimiter
  const delimiter = detectDelimiter(lines.slice(0, 10));
  if (!delimiter) return null;

  const parsed = Papa.parse(content, { delimiter, skipEmptyLines: true });
  const data = parsed.data as string[][];
  if (data.length < 2) return null;

  // Step 1b: Detect preamble lines to skip
  // Find the expected column count (most frequent count > 1)
  const colCountFreq = new Map<number, number>();
  for (const row of data) {
    const len = row.length;
    if (len <= 1) continue;
    colCountFreq.set(len, (colCountFreq.get(len) || 0) + 1);
  }
  let expectedColCount = 0;
  let maxColFreq = 0;
  for (const [count, freq] of colCountFreq) {
    if (freq > maxColFreq) {
      maxColFreq = freq;
      expectedColCount = count;
    }
  }

  // Skip leading rows that don't match the expected column count
  let skipLines = 0;
  for (let i = 0; i < data.length; i++) {
    if (data[i].length >= expectedColCount) break;
    skipLines++;
  }

  const effectiveData = data.slice(skipLines);
  if (effectiveData.length < 2) return null;

  // Step 2: Detect header
  const hasHeader = detectHeader(effectiveData[0]);

  const dataStartIdx = hasHeader ? 1 : 0;
  const sampleRows = effectiveData.slice(dataStartIdx, dataStartIdx + 20);
  if (sampleRows.length === 0) return null;

  const colCount = Math.max(...effectiveData.slice(0, 10).map((r) => r.length));

  // Step 3: Detect date column + format
  const dateResult = detectDateColumn(sampleRows, colCount);
  if (!dateResult) return null;

  // Step 3b: Find ALL date-like columns (to exclude from amount candidates)
  const dateLikeCols = new Set<number>();
  for (let col = 0; col < colCount; col++) {
    for (const fmt of DATE_FORMATS) {
      let success = 0;
      let total = 0;
      for (const row of sampleRows) {
        const cell = row[col]?.trim();
        if (!cell) continue;
        total++;
        if (parseDate(cell, fmt)) success++;
      }
      if (total > 0 && success / total >= 0.8) {
        dateLikeCols.add(col);
        break;
      }
    }
  }

  // Step 4: Detect numeric columns
  const numericCols = detectNumericColumns(sampleRows, colCount);

  // Step 5: Detect balance columns and exclude them + date-like columns
  const balanceCols = detectBalanceColumns(sampleRows, numericCols);
  const amountCandidates = numericCols.filter(
    (c) => !balanceCols.has(c) && !dateLikeCols.has(c)
  );

  // Step 6: Detect description column
  const descriptionCol = detectDescriptionColumn(
    sampleRows,
    colCount,
    dateResult.column,
    new Set([...numericCols, ...dateLikeCols])
  );

  // Step 7: Determine amount mode
  const amountResult = detectAmountMode(sampleRows, amountCandidates);
  if (!amountResult) return null;

  const mapping: ColumnMapping = {
    date: dateResult.column,
    description: descriptionCol,
  };

  let signConvention: SignConvention = "negative_expense";

  if (amountResult.mode === "debit_credit") {
    mapping.debitAmount = amountResult.debitCol;
    mapping.creditAmount = amountResult.creditCol;
  } else {
    mapping.amount = amountResult.amountCol;
    signConvention = amountResult.signConvention;
  }

  return {
    delimiter,
    hasHeader,
    skipLines,
    dateFormat: dateResult.format,
    columnMapping: mapping,
    amountMode: amountResult.mode,
    signConvention,
  };
}

function detectDelimiter(lines: string[]): string | null {
  let bestDelimiter: string | null = null;
  let bestScore = 0;

  for (const delim of DELIMITERS) {
    const counts = lines.map(
      (line) =>
        Papa.parse(line, { delimiter: delim }).data[0] as string[]
    ).map((row) => row.length);

    // Find the most frequent column count > 1 (tolerates preamble lines)
    const countFreq = new Map<number, number>();
    for (const c of counts) {
      if (c <= 1) continue;
      countFreq.set(c, (countFreq.get(c) || 0) + 1);
    }
    if (countFreq.size === 0) continue;

    let modeCount = 0;
    let modeFreq = 0;
    for (const [count, freq] of countFreq) {
      if (freq > modeFreq || (freq === modeFreq && count > modeCount)) {
        modeFreq = freq;
        modeCount = count;
      }
    }

    const score = (modeFreq / counts.length) * modeCount;

    if (score > bestScore) {
      bestScore = score;
      bestDelimiter = delim;
    }
  }

  return bestDelimiter;
}

function detectHeader(firstRow: string[]): boolean {
  // A header row typically has no parseable dates and no parseable numbers
  let hasDate = false;
  let hasNumber = false;

  for (const cell of firstRow) {
    const trimmed = cell?.trim();
    if (!trimmed) continue;

    // Check for number
    if (!isNaN(parseFrenchAmount(trimmed))) {
      hasNumber = true;
    }

    // Check for date
    for (const fmt of DATE_FORMATS) {
      if (parseDate(trimmed, fmt)) {
        hasDate = true;
        break;
      }
    }
  }

  return !hasDate && !hasNumber;
}

function detectDateColumn(
  rows: string[][],
  colCount: number
): { column: number; format: string } | null {
  let bestCol = -1;
  let bestFormat = "";
  let bestRate = 0;

  for (let col = 0; col < colCount; col++) {
    for (const fmt of DATE_FORMATS) {
      let success = 0;
      let total = 0;

      for (const row of rows) {
        const cell = row[col]?.trim();
        if (!cell) continue;
        total++;
        if (parseDate(cell, fmt)) {
          success++;
        }
      }

      if (total === 0) continue;
      const rate = success / total;
      if (rate > bestRate) {
        bestRate = rate;
        bestCol = col;
        bestFormat = fmt;
      }
    }
  }

  if (bestRate < 0.8 || bestCol < 0) return null;

  return { column: bestCol, format: bestFormat };
}

function detectNumericColumns(rows: string[][], colCount: number): number[] {
  const result: number[] = [];

  for (let col = 0; col < colCount; col++) {
    let numericCount = 0;
    let nonEmpty = 0;
    const distinctValues = new Set<number>();

    for (const row of rows) {
      const cell = row[col]?.trim();
      if (!cell) continue;
      nonEmpty++;
      const val = parseFrenchAmount(cell);
      if (!isNaN(val)) {
        numericCount++;
        distinctValues.add(val);
      }
    }

    if (nonEmpty > 0 && numericCount / nonEmpty >= 0.5) {
      // Exclude constant-value columns (e.g., account numbers, transit numbers)
      if (distinctValues.size <= 1 && nonEmpty > 2) continue;
      result.push(col);
    }
  }

  return result;
}

function detectBalanceColumns(
  rows: string[][],
  numericCols: number[]
): Set<number> {
  const balanceCols = new Set<number>();
  if (numericCols.length < 2 || rows.length < 3) return balanceCols;

  const TOLERANCE = 0.015; // tolerance for floating-point comparison

  // Parse all numeric values once
  const values: Map<number, (number | null)[]> = new Map();
  for (const col of numericCols) {
    values.set(
      col,
      rows.map((row) => {
        const cell = row[col]?.trim();
        if (!cell) return null;
        const v = parseFrenchAmount(cell);
        return isNaN(v) ? null : v;
      })
    );
  }

  for (const balCol of numericCols) {
    const balVals = values.get(balCol)!;

    // Test single-column balance: balance[i] ≈ balance[i-1] ± amount[i]
    for (const amtCol of numericCols) {
      if (amtCol === balCol) continue;
      const amtVals = values.get(amtCol)!;

      let matches = 0;
      let tested = 0;

      for (let i = 1; i < rows.length; i++) {
        if (balVals[i] === null || balVals[i - 1] === null || amtVals[i] === null)
          continue;
        tested++;

        const diff = balVals[i]! - balVals[i - 1]!;
        // balance[i] = balance[i-1] + amount[i] OR balance[i] = balance[i-1] - amount[i]
        if (
          Math.abs(diff - amtVals[i]!) < TOLERANCE ||
          Math.abs(diff + amtVals[i]!) < TOLERANCE
        ) {
          matches++;
        }
      }

      if (tested >= 2 && matches / tested >= 0.8) {
        balanceCols.add(balCol);
        break;
      }
    }

    if (balanceCols.has(balCol)) continue;

    // Test two-column balance: balance[i] ≈ balance[i-1] - debit[i] + credit[i]
    for (let a = 0; a < numericCols.length; a++) {
      for (let b = a + 1; b < numericCols.length; b++) {
        const colA = numericCols[a];
        const colB = numericCols[b];
        if (colA === balCol || colB === balCol) continue;

        const valsA = values.get(colA)!;
        const valsB = values.get(colB)!;

        let matches = 0;
        let tested = 0;

        for (let i = 1; i < rows.length; i++) {
          if (balVals[i] === null || balVals[i - 1] === null) continue;
          const da = valsA[i] ?? 0;
          const db = valsB[i] ?? 0;
          tested++;

          const diff = balVals[i]! - balVals[i - 1]!;
          // Try both orderings: diff ≈ -colA + colB or diff ≈ colA - colB
          if (
            Math.abs(diff - (-da + db)) < TOLERANCE ||
            Math.abs(diff - (da - db)) < TOLERANCE
          ) {
            matches++;
          }
        }

        if (tested >= 2 && matches / tested >= 0.8) {
          balanceCols.add(balCol);
          break;
        }
      }
      if (balanceCols.has(balCol)) break;
    }
  }

  return balanceCols;
}

function detectDescriptionColumn(
  rows: string[][],
  colCount: number,
  dateCol: number,
  numericCols: Set<number>
): number {
  let bestCol = 0;
  let bestAvgLen = 0;

  for (let col = 0; col < colCount; col++) {
    if (col === dateCol || numericCols.has(col)) continue;

    let totalLen = 0;
    let count = 0;

    for (const row of rows) {
      const cell = row[col]?.trim();
      if (!cell) continue;
      totalLen += cell.length;
      count++;
    }

    const avgLen = count > 0 ? totalLen / count : 0;
    if (avgLen > bestAvgLen) {
      bestAvgLen = avgLen;
      bestCol = col;
    }
  }

  return bestCol;
}

interface SingleAmountResult {
  mode: "single";
  amountCol: number;
  signConvention: SignConvention;
}

interface DebitCreditResult {
  mode: "debit_credit";
  debitCol: number;
  creditCol: number;
}

type AmountModeResult = SingleAmountResult | DebitCreditResult;

function detectAmountMode(
  rows: string[][],
  amountCandidates: number[]
): AmountModeResult | null {
  if (amountCandidates.length === 0) return null;

  if (amountCandidates.length === 1) {
    return detectSingleAmount(rows, amountCandidates[0]);
  }

  // Check for sparse-complementary pair (debit/credit pattern)
  for (let a = 0; a < amountCandidates.length; a++) {
    for (let b = a + 1; b < amountCandidates.length; b++) {
      const colA = amountCandidates[a];
      const colB = amountCandidates[b];

      if (isSparseComplementary(rows, colA, colB)) {
        return { mode: "debit_credit", debitCol: colA, creditCol: colB };
      }
    }
  }

  // No complementary pair found — pick best single amount column
  const bestCol = pickBestAmountColumn(rows, amountCandidates);
  return detectSingleAmount(rows, bestCol);
}

/** Pick the best amount column: prefer columns with decimal values (cents). */
function pickBestAmountColumn(rows: string[][], candidates: number[]): number {
  let bestCol = candidates[0];
  let bestScore = -1;

  for (const col of candidates) {
    let decimalCount = 0;
    let nonEmpty = 0;

    for (const row of rows) {
      const cell = row[col]?.trim();
      if (!cell) continue;
      const val = parseFrenchAmount(cell);
      if (isNaN(val)) continue;
      nonEmpty++;
      if (!Number.isInteger(val)) {
        decimalCount++;
      }
    }

    const score = nonEmpty > 0 ? decimalCount / nonEmpty : 0;
    if (score > bestScore) {
      bestScore = score;
      bestCol = col;
    }
  }

  return bestCol;
}

function detectSingleAmount(
  rows: string[][],
  col: number
): SingleAmountResult {
  let negCount = 0;
  let total = 0;

  for (const row of rows) {
    const cell = row[col]?.trim();
    if (!cell) continue;
    const val = parseFrenchAmount(cell);
    if (isNaN(val)) continue;
    total++;
    if (val < 0) negCount++;
  }

  // If most values are negative, they likely represent expenses as negative
  const signConvention: SignConvention =
    total > 0 && negCount / total > 0.5
      ? "negative_expense"
      : "positive_expense";

  return { mode: "single", amountCol: col, signConvention };
}

function isSparseComplementary(
  rows: string[][],
  colA: number,
  colB: number
): boolean {
  let complementary = 0;
  let total = 0;

  for (const row of rows) {
    const cellA = row[colA]?.trim();
    const cellB = row[colB]?.trim();
    const valA = cellA ? parseFrenchAmount(cellA) : NaN;
    const valB = cellB ? parseFrenchAmount(cellB) : NaN;
    const hasA = !isNaN(valA) && valA !== 0;
    const hasB = !isNaN(valB) && valB !== 0;

    if (!hasA && !hasB) continue;
    total++;

    // Complementary: exactly one has a value
    if (hasA !== hasB) {
      complementary++;
    }
  }

  return total > 0 && complementary / total >= 0.7;
}

// -----------------------------------------------------------------------------
// Holdings CSV detection (Issue #245) — a detailed balance account can import a
// CSV of positions instead of typing them one by one. This is a SEPARATE flow
// from the transaction import above: `autoDetectConfig` is coupled to the
// date/amount transaction model, whereas a holdings CSV maps to
// symbol / quantity / unit_price / book_cost. Price + book_cost are OPTIONAL
// (flexible price detection): when the CSV has no price column the mapping
// leaves it `null` and the holdings import without a price (the user fetches or
// types it afterwards). Detection is best-effort and the UI lets the user
// adjust every column, so an imperfect guess is always recoverable.
// -----------------------------------------------------------------------------

export interface HoldingColumnMapping {
  /** Column index of the security symbol/ticker (required). */
  symbol: number;
  /** Column index of the quantity held (required). */
  quantity: number;
  /** Column index of the unit price, or null when the CSV has no price column. */
  unit_price: number | null;
  /** Column index of the acquisition cost basis, or null when absent. */
  book_cost: number | null;
}

export interface HoldingCsvAnalysis {
  delimiter: string;
  hasHeader: boolean;
  /** Header labels (actual cells when `hasHeader`, else `Col 0`, `Col 1`, …). */
  headers: string[];
  /** DATA rows only (the header row, if any, is stripped). */
  rows: string[][];
  mapping: HoldingColumnMapping;
}

// Header keyword sets, matched against accent-stripped, alphanumeric-only
// header cells. Bilingual (FR default + EN). Order inside each list is priority
// order for the primary-keyword pass.
const SYMBOL_HEADER_KEYWORDS = ["symbol", "symbole", "ticker", "titre"];
const QUANTITY_HEADER_KEYWORDS = [
  "quantity",
  "quantite",
  "qty",
  "qte",
  "shares",
  "actions",
  "parts",
  "unites",
  "units",
  "nombre",
];
const PRICE_HEADER_KEYWORDS = [
  "prixunitaire",
  "unitprice",
  "marketprice",
  "price",
  "prix",
  "cours",
  "cotation",
  "cloture",
  "close",
];
const BOOKCOST_HEADER_KEYWORDS = [
  "bookcost",
  "costbasis",
  "prixderevient",
  "acquisition",
  "revient",
  "cost",
  "cout",
];
// Value/market-value columns must never be auto-picked as price or book_cost.
const VALUE_HEADER_KEYWORDS = ["value", "valeur", "montant", "marchande"];

/** Accent-strip + lowercase + keep alphanumerics only (for header matching). */
function normalizeHeaderCell(s: string): string {
  return (s ?? "")
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]/g, "");
}

/**
 * Find the first unused column whose normalized header contains one of the
 * keywords (keywords tried in priority order). `exclude` skips columns whose
 * header contains any excluded token (e.g. a "value" column for price).
 */
function matchHeaderColumn(
  normalizedHeaders: string[],
  keywords: string[],
  used: Set<number>,
  exclude: string[] = []
): number | null {
  for (const kw of keywords) {
    for (let i = 0; i < normalizedHeaders.length; i++) {
      if (used.has(i)) continue;
      const h = normalizedHeaders[i];
      if (!h) continue;
      if (exclude.some((ex) => h.includes(ex))) continue;
      if (h.includes(kw)) return i;
    }
  }
  return null;
}

/** Pick the shortest-average-length non-numeric, unused text column (symbols
 *  are short tokens; a name/description column is longer). */
function pickSymbolColumn(
  rows: string[][],
  colCount: number,
  numericCols: Set<number>,
  used: Set<number>
): number | null {
  let best: number | null = null;
  let bestAvg = Infinity;
  for (let col = 0; col < colCount; col++) {
    if (used.has(col) || numericCols.has(col)) continue;
    let totalLen = 0;
    let count = 0;
    for (const row of rows) {
      const cell = row[col]?.trim();
      if (!cell) continue;
      totalLen += cell.length;
      count++;
    }
    if (count === 0) continue;
    const avg = totalLen / count;
    if (avg < bestAvg) {
      bestAvg = avg;
      best = col;
    }
  }
  return best;
}

/** Pick an unused numeric column, optionally preferring integer-heavy (quantity)
 *  or decimal-heavy (price) columns. Returns null when none remain. */
function pickNumericColumn(
  rows: string[][],
  numericCols: number[],
  used: Set<number>,
  opts: { preferIntegers?: boolean; preferDecimals?: boolean }
): number | null {
  let best: number | null = null;
  let bestScore = -1;
  for (const col of numericCols) {
    if (used.has(col)) continue;
    let ints = 0;
    let decimals = 0;
    let nonEmpty = 0;
    for (const row of rows) {
      const cell = row[col]?.trim();
      if (!cell) continue;
      const v = parseFrenchAmount(cell);
      if (isNaN(v)) continue;
      nonEmpty++;
      if (Number.isInteger(v)) ints++;
      else decimals++;
    }
    if (nonEmpty === 0) continue;
    let score = 1;
    if (opts.preferIntegers) score = ints / nonEmpty;
    else if (opts.preferDecimals) score = decimals / nonEmpty;
    if (score > bestScore) {
      bestScore = score;
      best = col;
    }
  }
  return best;
}

/**
 * Detect the symbol / quantity / unit_price / book_cost columns of a holdings
 * CSV. `data` is the parsed 2-D array INCLUDING the header row when
 * `hasHeader`. Returns a best-effort mapping (symbol + quantity always set so
 * the editor can render), or null only when there is no usable data.
 * Exported for unit tests.
 */
export function autoDetectHoldingColumns(
  data: string[][],
  hasHeader: boolean
): HoldingColumnMapping | null {
  if (data.length === 0) return null;
  const colCount = Math.max(...data.map((r) => r.length));
  if (colCount === 0) return null;
  const dataRows = hasHeader ? data.slice(1) : data;
  if (dataRows.length === 0) return null;

  const used = new Set<number>();
  let symbol: number | null = null;
  let quantity: number | null = null;
  let unitPrice: number | null = null;
  let bookCost: number | null = null;

  // Step 1 — header keyword matching (most reliable when present).
  if (hasHeader) {
    const normalized = data[0].map(normalizeHeaderCell);
    symbol = matchHeaderColumn(normalized, SYMBOL_HEADER_KEYWORDS, used);
    if (symbol !== null) used.add(symbol);
    quantity = matchHeaderColumn(normalized, QUANTITY_HEADER_KEYWORDS, used);
    if (quantity !== null) used.add(quantity);
    unitPrice = matchHeaderColumn(
      normalized,
      PRICE_HEADER_KEYWORDS,
      used,
      VALUE_HEADER_KEYWORDS
    );
    if (unitPrice !== null) used.add(unitPrice);
    bookCost = matchHeaderColumn(
      normalized,
      BOOKCOST_HEADER_KEYWORDS,
      used,
      VALUE_HEADER_KEYWORDS
    );
    if (bookCost !== null) used.add(bookCost);
  }

  // A value / market-value column must never be auto-assigned to a numeric
  // target by the heuristics below (it is qty × price, not a source column).
  // Mark such columns used up-front so the fallback pickers skip them.
  if (hasHeader) {
    const normalized = data[0].map(normalizeHeaderCell);
    normalized.forEach((h, i) => {
      if (!used.has(i) && VALUE_HEADER_KEYWORDS.some((v) => h && h.includes(v))) {
        used.add(i);
      }
    });
  }

  // Step 2 — numeric/text heuristics fill any column the header didn't resolve.
  const sampleRows = dataRows.slice(0, 20);
  const numericCols = detectNumericColumns(sampleRows, colCount);
  const numericSet = new Set(numericCols);

  if (symbol === null) {
    symbol = pickSymbolColumn(sampleRows, colCount, numericSet, used);
    if (symbol !== null) used.add(symbol);
  }
  if (quantity === null) {
    quantity = pickNumericColumn(sampleRows, numericCols, used, {
      preferIntegers: true,
    });
    if (quantity !== null) used.add(quantity);
  }
  if (unitPrice === null) {
    unitPrice = pickNumericColumn(sampleRows, numericCols, used, {
      preferDecimals: true,
    });
    if (unitPrice !== null) used.add(unitPrice);
  }
  if (bookCost === null) {
    bookCost = pickNumericColumn(sampleRows, numericCols, used, {});
    if (bookCost !== null) used.add(bookCost);
  }

  // symbol + quantity are required; fall back to sane defaults so the editor
  // always has something to show (the user can correct it).
  if (symbol === null) symbol = 0;
  if (quantity === null) quantity = symbol === 0 && colCount > 1 ? 1 : 0;

  return { symbol, quantity, unit_price: unitPrice, book_cost: bookCost };
}

/**
 * Full holdings-CSV analysis: preprocess quoted lines, detect the delimiter and
 * header, then the column mapping. Returns the header labels + DATA rows (header
 * stripped) so the caller can render a mapping editor + preview and feed the
 * rows to `holdingsFromCsvRows`. Null when the content has no usable rows.
 * Exported for unit tests.
 */
export function analyzeHoldingsCsv(
  rawContent: string
): HoldingCsvAnalysis | null {
  const content = preprocessQuotedCSV(rawContent);
  const nonEmptyLines = content.split(/\r?\n/).filter((l) => l.trim());
  if (nonEmptyLines.length === 0) return null;

  const delimiter = detectDelimiter(nonEmptyLines.slice(0, 10));
  if (!delimiter) return null;

  const parsed = Papa.parse(content, { delimiter, skipEmptyLines: true });
  const data = (parsed.data as string[][]).filter((r) =>
    r.some((c) => (c ?? "").trim() !== "")
  );
  if (data.length === 0) return null;

  const hasHeader = detectHeader(data[0]);
  const mapping = autoDetectHoldingColumns(data, hasHeader);
  if (!mapping) return null;

  const colCount = Math.max(...data.map((r) => r.length));
  const headers = hasHeader
    ? data[0].map((h, i) => (h ?? "").trim() || `Col ${i}`)
    : Array.from({ length: colCount }, (_, i) => `Col ${i}`);
  const rows = hasHeader ? data.slice(1) : data;

  return { delimiter, hasHeader, headers, rows, mapping };
}
