// Pure logic for the universal import engine — mapping ANY accounting
// software's chart/general-ledger spreadsheet export onto the shapes our
// existing staging pipeline already consumes. No I/O, no DB, no routes here
// on purpose — everything is testable from plain string[][] rows, matching
// tools/csv-map-logic.ts and tools/journal-logic.ts. See
// docs/superpowers/specs/2026-08-02-switch-from-anywhere-design.md, Part B,
// and docs/superpowers/plans/2026-08-02-switch-from-anywhere.md, Task 3.
//
// Layers, used in sequence by the mapping wizard:
//  1. sniffHeaders: raw rows (+ a hasHeader flag the user confirms) ->
//     headers + a preview. CSV text becomes rows via csv-map-logic's
//     parseCsvRows (re-exported below — ONE RFC-hardened CSV parser in this
//     codebase, never a fork); xlsx becomes rows at the server layer.
//  2. Chart: collectDistinctTypes -> fuzzyPrefillTypeMap (guesses the UI
//     always confirms) -> applyChartMapping -> account rows shaped like the
//     QB chart loader's output (full-path names + leaf aliases).
//  3. Journal: sniffDateFormat (confirmable) -> applyJournalMapping ->
//     JournalEntry groups byte-compatible with tools/journal-logic.ts, so
//     tools/lib/journal-load.ts's staging pipeline consumes them unchanged.
//
// Money is string ops ONLY (CLAUDE.md §2/§12): amounts are parsed by digit
// substring, carried as integer ten-thousandths (matching NUMERIC(19,4)'s
// scale), and re-rendered as plain 2dp strings. More than 2 decimal places
// in an input is a loud row error — the downstream pipeline is cents-based
// (journal-logic's centsFromAmountString truncates a 3rd/4th decimal), so
// letting sub-cent amounts through would be a silent-divergence hazard.
// parseFloat/Number never touches a raw amount string; only pure-digit
// whole/fraction substrings are ever handed to Number(). Group balance
// totals are BigInt — a long, huge-magnitude group's float sum can round
// past 2^53 and falsely compare equal (or unequal).
//
// Every lookup keyed by USER data (raw type strings, header names) is
// hasOwnProperty-guarded and internal lookup tables carry a null prototype —
// a source file whose account type is literally "constructor" or "__proto__"
// must fail validation like any other unmapped type, not resolve to an
// inherited Object.prototype member.

import type { JournalEntry, JournalLine } from "./journal-logic.js";
import type { LedgerType, AccountSubtype } from "./chart-logic.js";

// One CSV parser for the whole codebase — reuse, never fork (see header).
export { parseCsvRows } from "./csv-map-logic.js";
export type { JournalEntry, JournalLine } from "./journal-logic.js";
export type { LedgerType, AccountSubtype } from "./chart-logic.js";

/** Own-property check for lookups keyed by user-supplied strings — a raw
 *  type named "toString" must MISS, not hit Object.prototype.toString. */
function hasOwn(obj: object, key: string): boolean {
  return Object.prototype.hasOwnProperty.call(obj, key);
}

/** Freezes a lookup table onto a null prototype so user-supplied keys can
 *  never resolve to inherited Object.prototype members. */
function nullProtoTable<T>(entries: Record<string, T>): Readonly<Record<string, T>> {
  return Object.freeze(Object.assign(Object.create(null) as Record<string, T>, entries));
}

/** 1-indexed row number within the raw parsed rows (rows[0] is row 1),
 *  matching csv-map-logic's CsvMappedError convention. */
export interface RowError {
  row: number;
  message: string;
}

// ---------------------------------------------------------------------------
// 1. Header sniff
// ---------------------------------------------------------------------------

export interface HeaderSniff {
  /** Trimmed header labels; generated "Column N" names for headerless files. */
  headers: string[];
  /** First 6 data rows, for the mapping-step preview. */
  sampleRows: string[][];
  columnCount: number;
  /** 0-based index into `rows` where data begins — the value callers should
   *  put in a mapping's headerRows. */
  dataStartIndex: number;
}

const SAMPLE_ROW_COUNT = 6;

/**
 * Headers + preview for the mapping UI. `hasHeader` is the user-confirmable
 * flag (the wizard defaults it on and shows the first row either way, so a
 * wrong guess is visible immediately). Headerless files get generated
 * "Column 1".."Column N" labels so every dropdown still has something to say.
 */
export function sniffHeaders(rows: string[][], hasHeader: boolean): HeaderSniff {
  const columnCount = rows.reduce((max, r) => Math.max(max, r.length), 0);

  if (rows.length === 0) {
    return { headers: [], sampleRows: [], columnCount: 0, dataStartIndex: 0 };
  }

  if (hasHeader) {
    const headers = Array.from({ length: columnCount }, (_, c) => (rows[0]![c] ?? "").trim());
    return {
      headers,
      sampleRows: rows.slice(1, 1 + SAMPLE_ROW_COUNT),
      columnCount,
      dataStartIndex: 1,
    };
  }

  return {
    headers: Array.from({ length: columnCount }, (_, c) => `Column ${c + 1}`),
    sampleRows: rows.slice(0, SAMPLE_ROW_COUNT),
    columnCount,
    dataStartIndex: 0,
  };
}

function isEmptyRow(row: string[]): boolean {
  return row.every((c) => c.trim() === "");
}

function checkColIndex(label: string, col: number | undefined, required: boolean): void {
  if (col === undefined) {
    if (required) throw new Error(`${label} is not set.`);
    return;
  }
  if (!Number.isInteger(col) || col < 0) {
    throw new Error(`${label} must be a non-negative column index.`);
  }
}

// ---------------------------------------------------------------------------
// Amounts — string ops only, integer ten-thousandths (4dp) internally
// ---------------------------------------------------------------------------

const UNITS_PER_WHOLE = 10000; // NUMERIC(19,4): 4 decimal places max

export interface ParsedAmount {
  /** Signed integer ten-thousandths (4 implied decimal places, always a
   *  multiple of 100 — inputs are dollars-and-cents only). */
  units: number;
  /** Canonical string: exactly 2 decimal places, no separators/symbols. */
  text: string;
}

/**
 * Some exports print "-" or "--" in an empty amount cell instead of leaving
 * it blank. "0" is NOT blank — it is a parseable zero, and whether zero is
 * acceptable depends on the context (debit/credit pairs treat it like blank;
 * a signed amount column rejects it loudly).
 */
export function isBlankAmountCell(raw: string): boolean {
  const s = raw.trim();
  return s === "" || s === "-" || s === "--";
}

/**
 * Renders integer ten-thousandths as a plain dollars-and-cents string —
 * always exactly 2 decimal places, matching what the staging pipeline's
 * cents-based helpers expect. Accepts bigint so group TOTALS (which can
 * round past 2^53 as floats) render without ever becoming a Number. Units
 * with sub-cent residue are a caller bug (the parser only ever produces
 * multiples of 100) and throw rather than silently truncating money.
 */
export function unitsToAmountString(units: number | bigint): string {
  const u = typeof units === "bigint" ? units : BigInt(units);
  const negative = u < 0n;
  const abs = negative ? -u : u;
  if (abs % 100n !== 0n) {
    throw new Error("sub-cent units cannot be rendered as a dollars-and-cents amount.");
  }
  const cents = abs / 100n;
  const whole = cents / 100n;
  const fraction = String(cents % 100n).padStart(2, "0");
  return `${negative ? "-" : ""}${whole}.${fraction}`;
}

/**
 * Signed decimal with tolerance for every kind of spreadsheet dirt we accept:
 * "(500.00)" parentheses negatives, "$" symbols (before OR inside the
 * parentheses), US comma thousands separators, trailing minus ("500.00-"),
 * leading plus, and stray whitespace. US format ONLY — an unambiguous
 * European-format amount ("1.234,56") gets a clear, specific error rather
 * than a silent misread. Dollars and cents only: more than 2 decimal places
 * is a loud error, because the downstream cents-based pipeline would
 * silently truncate the rest.
 */
export function parseUniversalAmount(raw: string): ParsedAmount {
  let s = raw.trim();
  if (s === "") {
    throw new Error("amount is blank.");
  }

  // Currency symbols go first so "$(1,000.00)" and "($1,000.00)" both work.
  s = s.replace(/\$/g, "").trim();

  let negative = false;
  if (s.startsWith("(") && s.endsWith(")")) {
    negative = true;
    s = s.slice(1, -1).trim();
  }
  if (s.endsWith("-")) {
    negative = true;
    s = s.slice(0, -1).trim();
  }
  if (s.startsWith("-")) {
    negative = true;
    s = s.slice(1).trim();
  } else if (s.startsWith("+")) {
    s = s.slice(1).trim();
  }

  const europeanError = (): never => {
    throw new Error(
      `amount "${raw}" looks like European number format (1.234,56) — ` +
        `this import reads US-format amounts like 1,234.56.`,
    );
  };

  if (s.includes(",")) {
    // With commas present, either the commas are valid US thousands grouping
    // or the value is European (comma decimal) — never guess between them.
    if (!/^\d{1,3}(,\d{3})+(\.\d{1,2})?$/.test(s)) {
      if (/^\d+(\.\d{3})*,\d{1,2}$/.test(s)) europeanError();
      throw new Error(`unparseable amount "${raw}".`);
    }
    s = s.replace(/,/g, "");
  } else if (/^\d{1,3}(\.\d{3}){2,}$/.test(s)) {
    // "1.234.567" — multiple dot-groups can only be European grouping.
    europeanError();
  }

  if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) {
    if (/^\d*\.\d{3,}$/.test(s)) {
      throw new Error(
        `amount "${raw}" has more than 2 decimal places — export amounts in dollars and cents.`,
      );
    }
    throw new Error(`unparseable amount "${raw}".`);
  }

  const [wholePart = "", fractionPartRaw = ""] = s.split(".");
  if (wholePart.length > 11) {
    // 11 whole digits * 10^4 units stays far inside Number's exact-integer
    // range; anything bigger is refused rather than silently losing cents.
    throw new Error(`amount "${raw}" is too large.`);
  }
  const fractionPart = fractionPartRaw.padEnd(4, "0");
  let units = Number(wholePart || "0") * UNITS_PER_WHOLE + Number(fractionPart);
  if (negative) units = -units;
  return { units, text: unitsToAmountString(units) };
}

// ---------------------------------------------------------------------------
// Dates — format-driven string parsing only, never ambiguous Date() parsing
// ---------------------------------------------------------------------------

export type UniversalDateFormat =
  | "MM/DD/YYYY"
  | "M/D/YY"
  | "YYYY-MM-DD"
  | "DD/MM/YYYY"
  | "MMM D YYYY"
  | "D-MMM-YY"
  | "MM-DD-YYYY";

/** Sniff priority: unambiguous formats first, then US month-first defaults. */
const DATE_FORMATS: readonly UniversalDateFormat[] = [
  "YYYY-MM-DD",
  "MMM D YYYY",
  "D-MMM-YY",
  "MM/DD/YYYY",
  "M/D/YY",
  "MM-DD-YYYY",
  "DD/MM/YYYY",
];

/** Numeric formats whose month/day order a sample can fail to prove. */
const DAY_AMBIGUOUS_FORMATS: ReadonlySet<UniversalDateFormat> = new Set([
  "MM/DD/YYYY",
  "M/D/YY",
  "MM-DD-YYYY",
]);

export interface DateSniff {
  format: UniversalDateFormat | null;
  /** false means the UI must make the user confirm rather than pre-accept. */
  confident: boolean;
  ambiguousBecause?: string;
}

const MONTH_BY_PREFIX: Readonly<Record<string, number>> = nullProtoTable({
  jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
  jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
});

/** Two-digit years window at 70: "98" is 1998 (imported GL history reaches
 *  the 1990s), "26" is 2026. Diverges deliberately from csv-map-logic's
 *  always-20xx rule, which only ever sees recent bank statements. */
function windowTwoDigitYear(yy: number): number {
  return yy >= 70 ? 1900 + yy : 2000 + yy;
}

interface DateParts {
  year: number;
  month: number;
  day: number;
}

/** Returns null when `raw` does not have the format's SHAPE; calendar
 *  validity (month 1-12, day within month) is checked by the caller so the
 *  error can be specific. */
function extractDateParts(raw: string, format: UniversalDateFormat): DateParts | null {
  let m: RegExpExecArray | null;
  switch (format) {
    case "MM/DD/YYYY":
      m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(raw);
      return m ? { month: Number(m[1]), day: Number(m[2]), year: Number(m[3]) } : null;
    case "M/D/YY":
      m = /^(\d{1,2})\/(\d{1,2})\/(\d{2})$/.exec(raw);
      return m
        ? { month: Number(m[1]), day: Number(m[2]), year: windowTwoDigitYear(Number(m[3])) }
        : null;
    case "YYYY-MM-DD":
      m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw);
      return m ? { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) } : null;
    case "DD/MM/YYYY":
      m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(raw);
      return m ? { day: Number(m[1]), month: Number(m[2]), year: Number(m[3]) } : null;
    case "MMM D YYYY":
      m = /^([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})$/.exec(raw);
      if (!m) return null;
      return {
        month: MONTH_BY_PREFIX[m[1]!.slice(0, 3).toLowerCase()] ?? 0,
        day: Number(m[2]),
        year: Number(m[3]),
      };
    case "D-MMM-YY":
      // 4-digit years ("5-Jan-2026") are a common variant of the same shape.
      m = /^(\d{1,2})-([A-Za-z]{3,9})-(\d{2}|\d{4})$/.exec(raw);
      if (!m) return null;
      return {
        day: Number(m[1]),
        month: MONTH_BY_PREFIX[m[2]!.slice(0, 3).toLowerCase()] ?? 0,
        year: m[3]!.length === 2 ? windowTwoDigitYear(Number(m[3])) : Number(m[3]),
      };
    case "MM-DD-YYYY":
      m = /^(\d{1,2})-(\d{1,2})-(\d{4})$/.exec(raw);
      return m ? { month: Number(m[1]), day: Number(m[2]), year: Number(m[3]) } : null;
    default:
      throw new Error(`unknown date format "${String(format)}".`);
  }
}

/**
 * Explicit-format date parsing to ISO yyyy-mm-dd. Format-driven string
 * parsing only — the ambiguous JS `Date("...")` constructor is never used on
 * input (Date.UTC below is CONSTRUCTION from already-validated numbers, the
 * same days-in-month trick csv-map-logic uses). Validates the calendar
 * rather than trusting the input.
 */
export function parseUniversalDate(raw: string, format: UniversalDateFormat): string {
  const s = raw.trim();
  if (s === "") {
    throw new Error("date is blank.");
  }
  const parts = extractDateParts(s, format);
  if (parts === null) {
    throw new Error(`unparseable date "${raw}" — expected the ${format} format.`);
  }
  const { year, month, day } = parts;
  if (month < 1 || month > 12) {
    throw new Error(`invalid month in date "${raw}".`);
  }
  const maxDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
  if (day < 1 || day > maxDay) {
    throw new Error(`invalid day in date "${raw}".`);
  }
  return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}

function tryParseDate(raw: string, format: UniversalDateFormat): DateParts | null {
  const parts = extractDateParts(raw.trim(), format);
  if (parts === null) return null;
  const { year, month, day } = parts;
  if (month < 1 || month > 12) return null;
  const maxDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
  if (day < 1 || day > maxDay) return null;
  return parts;
}

/**
 * Best-fit date format across a whole sample of values (blank cells are
 * ignored). `confident: false` means the sample could not PROVE the guess —
 * classically, every day-of-month is 12 or less so month-first and day-first
 * both fit — and the UI must make the user confirm. Never returns a format
 * that fails on any sample value.
 */
export function sniffDateFormat(values: string[]): DateSniff {
  const sample = values.map((v) => v.trim()).filter((v) => v !== "");
  if (sample.length === 0) {
    return {
      format: null,
      confident: false,
      ambiguousBecause: "there are no date values to look at.",
    };
  }

  const candidates = DATE_FORMATS.filter((f) => sample.every((v) => tryParseDate(v, f) !== null));

  if (candidates.length === 0) {
    // Name the best near-miss so the error is actionable, not just "no".
    let bestFormat: UniversalDateFormat = DATE_FORMATS[0]!;
    let bestCount = -1;
    for (const f of DATE_FORMATS) {
      const count = sample.filter((v) => tryParseDate(v, f) !== null).length;
      if (count > bestCount) {
        bestCount = count;
        bestFormat = f;
      }
    }
    const example = sample.find((v) => tryParseDate(v, bestFormat) === null) ?? sample[0]!;
    return {
      format: null,
      confident: false,
      ambiguousBecause:
        `no single date format fits every value — "${example}" doesn't match ` +
        `${bestFormat}, the closest candidate.`,
    };
  }

  const format = candidates[0]!;
  if (DAY_AMBIGUOUS_FORMATS.has(format)) {
    const someDayProvesOrder = sample.some((v) => {
      const parts = tryParseDate(v, format);
      return parts !== null && parts.day > 12;
    });
    if (!someDayProvesOrder) {
      return {
        format,
        confident: false,
        ambiguousBecause:
          "every day-of-month in the sample is 12 or less, so month-first (US) " +
          "and day-first order both fit — month-first was assumed; please confirm.",
      };
    }
  }
  return { format, confident: true };
}

/** Batch form of parseUniversalDate: aligned ISO dates (null where a value
 *  failed) plus row-numbered errors, one per bad value. */
export function parseDates(
  values: string[],
  format: UniversalDateFormat,
): { dates: (string | null)[]; errors: RowError[] } {
  const dates: (string | null)[] = [];
  const errors: RowError[] = [];
  values.forEach((value, i) => {
    try {
      dates.push(parseUniversalDate(value, format));
    } catch (e) {
      dates.push(null);
      errors.push({ row: i + 1, message: (e as Error).message });
    }
  });
  return { dates, errors };
}

// ---------------------------------------------------------------------------
// 2. Chart mapping
// ---------------------------------------------------------------------------

export interface ChartMapping {
  /** Rows to skip before data begins (sniffHeaders' dataStartIndex). */
  headerRows: number;
  nameCol: number;
  typeCol: number;
  codeCol?: number;
  /** Column holding the account's PARENT name; full name becomes
   *  `parent<sep>name`. Mutually exclusive with fullPathCol. */
  parentCol?: number;
  /** Column holding the account's full path ("Assets:Checking"). May be the
   *  SAME column as nameCol (QuickBooks-style single Account column). */
  fullPathCol?: number;
  /** Separator inside full paths / between parent and name. Default ":". */
  pathSeparator?: string;
}

/** Distinct raw source-type string -> one of our five kinds. `null` means
 *  "not decided yet" — applyChartMapping refuses those rows loudly, so the
 *  UI must force a choice before anything proceeds. */
export type TypeValueMap = Record<string, LedgerType | null>;

// AccountSubtype comes from chart-logic.ts (the sixteen values the
// accounts.subtype CHECK matrix in db/migrations/0016_account_subtypes.sql
// allows) — imported and re-exported above, never a second copy.

export interface UniversalChartAccount {
  /** Full account name, kept exactly as the source spells it (path included). */
  name: string;
  type: LedgerType;
  code?: string;
  /** Extra names that should resolve to this account (leaf of a path when
   *  unique — mirrors the QB chart loader's subaccount aliasing). */
  aliases: string[];
  /** Emitted only when the source type clearly maps AND agrees with the
   *  user's chosen kind for that type. */
  subtype?: AccountSubtype;
}

/** Lowercase, punctuation collapsed to single spaces — "Credit-Card" and
 *  "credit card" compare equal, and phrase matching stays word-bounded.
 *  Exported so universal-presets.ts's header matching shares the exact
 *  same normalization rather than a second, drifting copy. */
export function normalizeWords(s: string): string {
  return s
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, " ")
    .trim();
}

function containsPhrase(haystackNormalized: string, phrase: string): boolean {
  return ` ${haystackNormalized} `.includes(` ${phrase} `);
}

// Synonym table for prefilling the type value-map. Phrases are normalized
// words; matching is word-bounded "contains", and the LONGEST matched phrase
// wins so "Cost of Sales" lands on expense, not income's "sales". These are
// PREFILL GUESSES — the UI always shows the mapping for confirmation, and an
// unmatched (or tied) raw type stays null so the user must decide.
const TYPE_SYNONYMS: ReadonlyArray<[LedgerType, readonly string[]]> = [
  ["asset", [
    "bank", "cash", "checking", "savings", "fixed asset", "fixed assets",
    "other current asset", "other current assets", "current asset", "current assets",
    "other asset", "other assets", "asset", "assets",
    "accounts receivable", "receivable", "receivables", "ar",
    "inventory", "prepayment", "prepayments", "undeposited funds",
  ]],
  ["liability", [
    "accounts payable", "payable", "payables", "ap",
    "credit card", "credit cards", "loan", "loans", "line of credit", "loc",
    "other current liability", "other current liabilities",
    "current liability", "current liabilities",
    "long term liability", "long term liabilities",
    "liability", "liabilities",
  ]],
  ["equity", ["equity", "retained earnings", "capital", "owner s equity"]],
  ["income", ["income", "revenue", "revenues", "sales", "other income"]],
  ["expense", [
    "expense", "expenses", "cogs", "cost of goods sold", "cost of goods",
    "cost of sales", "cost of revenue", "overhead", "overheads",
    "direct costs", "depreciation",
  ]],
];

/** Distinct raw type strings (trimmed, exact-dedupe) in first-appearance
 *  order — the list the type value-mapping UI presents. */
export function collectDistinctTypes(rows: string[][], mapping: ChartMapping): string[] {
  const seen = new Set<string>();
  const out: string[] = [];
  for (let i = mapping.headerRows; i < rows.length; i++) {
    const raw = (rows[i]?.[mapping.typeCol] ?? "").trim();
    if (raw === "" || seen.has(raw)) continue;
    seen.add(raw);
    out.push(raw);
  }
  return out;
}

/**
 * Guesses a kind for each distinct raw type via the synonym table. A raw
 * type matching nothing — or matching two kinds with equal strength — maps
 * to null, which the UI treats as "you must choose". Case- and
 * punctuation-insensitive, word-bounded.
 */
export function fuzzyPrefillTypeMap(rawTypes: string[]): TypeValueMap {
  // Null prototype: a raw type literally named "__proto__" (or "toString",
  // "constructor", ...) must become an ordinary own key, not a prototype
  // mutation or an inherited member.
  const map: TypeValueMap = Object.create(null) as TypeValueMap;
  for (const raw of rawTypes) {
    const normalized = normalizeWords(raw);
    let bestKind: LedgerType | null = null;
    let bestLength = 0;
    let tied = false;
    for (const [kind, phrases] of TYPE_SYNONYMS) {
      for (const phrase of phrases) {
        if (!containsPhrase(normalized, phrase)) continue;
        if (phrase.length > bestLength) {
          bestKind = kind;
          bestLength = phrase.length;
          tied = false;
        } else if (phrase.length === bestLength && kind !== bestKind) {
          tied = true;
        }
      }
    }
    map[raw] = tied ? null : bestKind;
  }
  return map;
}

const SUBTYPE_BY_NORMALIZED_TYPE: Readonly<Record<string, AccountSubtype>> = nullProtoTable({
  "bank": "bank", "bank account": "bank", "bank accounts": "bank",
  "cash": "bank", "checking": "bank", "savings": "bank",
  "credit card": "credit-card", "credit cards": "credit-card",
  "line of credit": "line-of-credit", "loc": "line-of-credit",
  "accounts receivable": "accounts-receivable", "ar": "accounts-receivable",
  "receivable": "accounts-receivable", "receivables": "accounts-receivable",
  "accounts payable": "accounts-payable", "ap": "accounts-payable",
  "payable": "accounts-payable", "payables": "accounts-payable",
  "other current asset": "other-current-asset", "other current assets": "other-current-asset",
  "current asset": "other-current-asset", "current assets": "other-current-asset",
  "inventory": "other-current-asset", "prepayment": "other-current-asset",
  "prepayments": "other-current-asset",
  "fixed asset": "fixed-asset", "fixed assets": "fixed-asset",
  "other asset": "other-asset", "other assets": "other-asset",
  "non current asset": "other-asset", "non current assets": "other-asset",
  "other current liability": "other-current-liability",
  "other current liabilities": "other-current-liability",
  "current liability": "other-current-liability",
  "current liabilities": "other-current-liability",
  "long term liability": "long-term-liability", "long term liabilities": "long-term-liability",
  "non current liability": "long-term-liability",
  "non current liabilities": "long-term-liability",
  "equity": "equity", "owner s equity": "equity",
  "retained earnings": "equity", "capital": "equity",
  "income": "income", "revenue": "income", "sales": "income",
  "other income": "other-income",
  "expense": "expense", "expenses": "expense", "overhead": "expense",
  "operating expense": "expense", "operating expenses": "expense",
  "depreciation": "expense",
  "cost of goods sold": "cost-of-goods-sold", "cogs": "cost-of-goods-sold",
  "cost of sales": "cost-of-goods-sold", "cost of goods": "cost-of-goods-sold",
  "direct costs": "cost-of-goods-sold",
  "other expense": "other-expense", "other expenses": "other-expense",
});

/** Kind each subtype belongs to — used to drop a subtype guess that would
 *  contradict the user's chosen kind for the raw type. */
const SUBTYPE_KIND: Readonly<Record<AccountSubtype, LedgerType>> = Object.freeze({
  "bank": "asset",
  "credit-card": "liability",
  "line-of-credit": "liability",
  "accounts-receivable": "asset",
  "accounts-payable": "liability",
  "other-current-asset": "asset",
  "fixed-asset": "asset",
  "other-asset": "asset",
  "other-current-liability": "liability",
  "long-term-liability": "liability",
  "equity": "equity",
  "income": "income",
  "other-income": "income",
  "expense": "expense",
  "cost-of-goods-sold": "expense",
  "other-expense": "expense",
});

/**
 * Our subtype key for a source type string, when the source type CLEARLY
 * maps (normalized whole-string equality against a known table — "Credit
 * Card" -> credit-card). Anything unclear stays undefined; subtype is an
 * optional nicety, never a guess worth being wrong about.
 */
export function subtypeFromSourceType(raw: string): AccountSubtype | undefined {
  const key = normalizeWords(raw);
  // hasOwn on top of the table's null prototype: belt and braces against a
  // source type named after any object member.
  return hasOwn(SUBTYPE_BY_NORMALIZED_TYPE, key) ? SUBTYPE_BY_NORMALIZED_TYPE[key] : undefined;
}

/**
 * Applies a CONFIRMED chart mapping + type value-map to raw rows. Per-row
 * problems (blank name/type, unmapped type, duplicate name) are collected
 * into `errors` — English, row-numbered — rather than thrown; a structurally
 * invalid MAPPING throws immediately (configuration mistake, not data).
 *
 * Path handling mirrors the QB chart loader: each row becomes one (leaf)
 * account whose name is the full path exactly as the source spells it, and
 * the path's leaf becomes an alias when it is unique across the chart — an
 * ambiguous leaf gets NO alias, so a journal row using it fails loudly at
 * resolve time instead of us guessing. A leaf that collides with another
 * account's full name also gets no alias (stricter than chart-logic's
 * computeLeafAliases, which never faces this because QB full names can't
 * equal a different account's leaf-with-parent).
 */
export function applyChartMapping(
  rows: string[][],
  mapping: ChartMapping,
  typeValueMap: TypeValueMap,
): { accounts: UniversalChartAccount[]; errors: RowError[] } {
  checkColIndex("nameCol", mapping.nameCol, true);
  checkColIndex("typeCol", mapping.typeCol, true);
  checkColIndex("codeCol", mapping.codeCol, false);
  checkColIndex("parentCol", mapping.parentCol, false);
  checkColIndex("fullPathCol", mapping.fullPathCol, false);
  if (mapping.parentCol !== undefined && mapping.fullPathCol !== undefined) {
    throw new Error("parentCol and fullPathCol are mutually exclusive — map one or the other.");
  }

  const separator = mapping.pathSeparator ?? ":";
  const errors: RowError[] = [];
  interface Pending {
    name: string;
    type: LedgerType;
    code?: string;
    subtype?: AccountSubtype;
  }
  const pending: Pending[] = [];
  const firstRowByName = new Map<string, number>();

  for (let i = mapping.headerRows; i < rows.length; i++) {
    const row = rows[i]!;
    const rowNum = i + 1;
    if (isEmptyRow(row)) continue;

    const baseName = (row[mapping.nameCol] ?? "").trim();
    if (baseName === "") {
      errors.push({ row: rowNum, message: "account name is blank." });
      continue;
    }

    const rawType = (row[mapping.typeCol] ?? "").trim();
    if (rawType === "") {
      errors.push({ row: rowNum, message: "account type is blank." });
      continue;
    }
    // Own-property guard: typeValueMap is caller/user data — a raw type
    // named "constructor" or "toString" must be UNMAPPED, not resolve to an
    // inherited Object.prototype member and slip through as a "type".
    const mappedType = hasOwn(typeValueMap, rawType) ? typeValueMap[rawType] ?? null : null;
    if (mappedType === null) {
      errors.push({
        row: rowNum,
        message:
          `account type "${rawType}" isn't mapped to one of the five kinds yet ` +
          `(asset, liability, equity, income, expense) — choose one in the type step.`,
      });
      continue;
    }

    let name = baseName;
    if (mapping.fullPathCol !== undefined) {
      const fullPath = (row[mapping.fullPathCol] ?? "").trim();
      if (fullPath !== "") name = fullPath;
    } else if (mapping.parentCol !== undefined) {
      const parent = (row[mapping.parentCol] ?? "").trim();
      if (parent !== "") name = `${parent}${separator}${baseName}`;
    }

    const firstRow = firstRowByName.get(name);
    if (firstRow !== undefined) {
      errors.push({
        row: rowNum,
        message: `duplicate account "${name}" — first seen on row ${firstRow}.`,
      });
      continue;
    }
    firstRowByName.set(name, rowNum);

    const codeRaw = mapping.codeCol !== undefined ? (row[mapping.codeCol] ?? "").trim() : "";
    const subtypeGuess = subtypeFromSourceType(rawType);
    pending.push({
      name,
      type: mappedType,
      code: codeRaw === "" ? undefined : codeRaw,
      subtype: subtypeGuess !== undefined && SUBTYPE_KIND[subtypeGuess] === mappedType
        ? subtypeGuess
        : undefined,
    });
  }

  // Leaf aliasing across the WHOLE chart (mirrors chart-logic's
  // computeLeafAliases, generalized to a configurable separator).
  const leafToNames = new Map<string, Set<string>>();
  for (const account of pending) {
    const idx = account.name.lastIndexOf(separator);
    if (idx === -1) continue;
    const leaf = account.name.slice(idx + separator.length).trim();
    if (leaf === "") continue;
    const set = leafToNames.get(leaf) ?? new Set<string>();
    set.add(account.name);
    leafToNames.set(leaf, set);
  }
  const allNames = new Set(pending.map((a) => a.name));

  const accounts: UniversalChartAccount[] = pending.map((account) => {
    const idx = account.name.lastIndexOf(separator);
    const aliases: string[] = [];
    if (idx !== -1) {
      const leaf = account.name.slice(idx + separator.length).trim();
      if (
        leaf !== "" &&
        leafToNames.get(leaf)?.size === 1 &&
        !allNames.has(leaf) // never shadow a real account's full name
      ) {
        aliases.push(leaf);
      }
    }
    const out: UniversalChartAccount = { name: account.name, type: account.type, aliases };
    if (account.code !== undefined) out.code = account.code;
    if (account.subtype !== undefined) out.subtype = account.subtype;
    return out;
  });

  return { accounts, errors };
}

// ---------------------------------------------------------------------------
// 3. Journal mapping
// ---------------------------------------------------------------------------

export type JournalAmountShape =
  | { kind: "debit-credit"; debitCol: number; creditCol: number }
  | {
      kind: "signed";
      amountCol: number;
      /** Which side a POSITIVE amount posts to. Xero journals, for example,
       *  print debits positive; some systems print credits positive. */
      positiveIs: "debit" | "credit";
    };

export type JournalGrouping =
  | { kind: "key-column"; groupKeyCol: number }
  /** LAST-RESORT mode for files with no transaction-number column:
   *  accumulate consecutive rows until debits equal credits, then close the
   *  group. Refuses (loudly, with row numbers) an unbalanced tail or a date
   *  change inside an unbalanced group — both are misgrouping signals. */
  | { kind: "consecutive-balanced" };

export interface UniversalJournalMapping {
  /** Rows to skip before data begins (sniffHeaders' dataStartIndex). */
  headerRows: number;
  dateCol: number;
  /** CONFIRMED date format (from sniffDateFormat + the user's say-so). */
  dateFormat: UniversalDateFormat;
  accountCol: number;
  amountShape: JournalAmountShape;
  grouping: JournalGrouping;
  memoCol?: number;
  nameCol?: number;
  /** Document/check/reference number — lands in the line's `num` field. */
  refCol?: number;
}

/** One fully-parsed data row, pre-grouping. Exactly one of debitUnits /
 *  creditUnits is non-zero (integer ten-thousandths). */
interface ParsedJournalRow {
  rowNum: number;
  dateIso: string;
  account: string;
  debitUnits: number;
  creditUnits: number;
  memo: string;
  name: string;
  num: string;
}

function parseRowAmounts(
  row: string[],
  shape: JournalAmountShape,
): { debitUnits: number; creditUnits: number } {
  if (shape.kind === "debit-credit") {
    const debitRaw = (row[shape.debitCol] ?? "").trim();
    const creditRaw = (row[shape.creditCol] ?? "").trim();
    let debitUnits = isBlankAmountCell(debitRaw) ? 0 : parseUniversalAmount(debitRaw).units;
    let creditUnits = isBlankAmountCell(creditRaw) ? 0 : parseUniversalAmount(creditRaw).units;
    if (debitUnits !== 0 && creditUnits !== 0) {
      throw new Error(
        `both debit ("${debitRaw}") and credit ("${creditRaw}") have a value — ` +
          `a journal line must have exactly one.`,
      );
    }
    // A negative amount in the debit column IS a credit (and vice versa) —
    // several products print reversals that way. The flip preserves balance.
    if (debitUnits < 0) {
      creditUnits = -debitUnits;
      debitUnits = 0;
    } else if (creditUnits < 0) {
      debitUnits = -creditUnits;
      creditUnits = 0;
    }
    if (debitUnits === 0 && creditUnits === 0) {
      throw new Error("neither debit nor credit has a value.");
    }
    return { debitUnits, creditUnits };
  }

  const raw = (row[shape.amountCol] ?? "").trim();
  if (isBlankAmountCell(raw)) {
    throw new Error("amount is blank.");
  }
  const units = parseUniversalAmount(raw).units;
  if (units === 0) {
    throw new Error("amount is zero — every journal line needs an amount.");
  }
  const positiveSide = shape.positiveIs;
  const magnitude = Math.abs(units);
  const side = units > 0 ? positiveSide : positiveSide === "debit" ? "credit" : "debit";
  return side === "debit"
    ? { debitUnits: magnitude, creditUnits: 0 }
    : { debitUnits: 0, creditUnits: magnitude };
}

function toJournalLine(parsed: ParsedJournalRow, entryKey: string, isFirst: boolean): JournalLine {
  return {
    entryKey,
    type: "",
    // QB-parser convention: only the entry's first line carries the date.
    dateIso: isFirst ? parsed.dateIso : "",
    num: parsed.num,
    name: parsed.name,
    memo: parsed.memo,
    account: parsed.account,
    debit: parsed.debitUnits !== 0 ? unitsToAmountString(parsed.debitUnits) : "",
    credit: parsed.creditUnits !== 0 ? unitsToAmountString(parsed.creditUnits) : "",
  };
}

function groupByKeyColumn(
  parsedRows: { parsed: ParsedJournalRow; keyRaw: string }[],
  errors: RowError[],
): JournalEntry[] {
  interface Group {
    startRow: number;
    dateIso: string;
    rows: ParsedJournalRow[];
  }
  const groups = new Map<string, Group>();
  const order: string[] = [];

  for (const { parsed, keyRaw } of parsedRows) {
    const key = keyRaw.trim();
    if (key === "") {
      errors.push({ row: parsed.rowNum, message: "entry grouping key is blank." });
      continue;
    }
    const group = groups.get(key);
    if (group === undefined) {
      groups.set(key, { startRow: parsed.rowNum, dateIso: parsed.dateIso, rows: [parsed] });
      order.push(key);
      continue;
    }
    if (group.dateIso !== parsed.dateIso) {
      errors.push({
        row: parsed.rowNum,
        message:
          `date ${parsed.dateIso} differs from ${group.dateIso}, the date of entry ` +
          `"${key}" (which started on row ${group.startRow}) — every line of one ` +
          `entry must share one date; check the grouping column.`,
      });
      continue;
    }
    group.rows.push(parsed);
  }

  return order.map((key) => {
    const group = groups.get(key)!;
    return {
      entryKey: key,
      dateIso: group.dateIso,
      lines: group.rows.map((r, idx) => toJournalLine(r, key, idx === 0)),
    };
  });
}

function groupConsecutiveBalanced(
  parseResults: ({ parsed: ParsedJournalRow } | { errorRow: number })[],
  errors: RowError[],
): JournalEntry[] {
  const entries: JournalEntry[] = [];
  interface Open {
    startRow: number;
    dateIso: string;
    rows: ParsedJournalRow[];
    // BigInt on purpose: a long, huge-magnitude group's totals can exceed
    // 2^53, where float accumulation rounds and can compare a truly
    // off-by-a-cent group as "balanced" (or a balanced one as off). Per-LINE
    // units stay number — a single value is bounded well under 2^53 by
    // parseUniversalAmount's 11-whole-digit cap.
    debitUnits: bigint;
    creditUnits: bigint;
  }
  let open: Open | null = null;

  function closeAsEntry(group: Open): void {
    const key = `row-${group.startRow}`;
    entries.push({
      entryKey: key,
      dateIso: group.dateIso,
      lines: group.rows.map((r, idx) => toJournalLine(r, key, idx === 0)),
    });
  }

  for (const result of parseResults) {
    if ("errorRow" in result) {
      // The bad row's own error is already recorded by the caller. A group
      // in progress can no longer be balance-checked, so it is abandoned —
      // with an error that names every affected row, never a silent drop.
      if (open !== null) {
        errors.push({
          row: open.startRow,
          message:
            `rows ${open.startRow}–${result.errorRow} can't be grouped because ` +
            `row ${result.errorRow} has an error — fix that row and re-import; ` +
            `nothing from these rows was kept.`,
        });
        open = null;
      }
      continue;
    }

    const parsed = result.parsed;
    if (open !== null && parsed.dateIso !== open.dateIso) {
      // The open group is unbalanced by definition (it would have closed),
      // and the date moved on — a classic sign this file can't be grouped
      // by "consecutive rows until balanced".
      errors.push({
        row: open.startRow,
        message:
          `the date changes on row ${parsed.rowNum} before the rows starting at ` +
          `row ${open.startRow} balance (debits ${unitsToAmountString(open.debitUnits)} ` +
          `vs credits ${unitsToAmountString(open.creditUnits)}) — this file doesn't ` +
          `look safe to group by "consecutive rows until balanced"; use a ` +
          `transaction-number column if the file has one.`,
      });
      open = null;
    }

    if (open === null) {
      open = {
        startRow: parsed.rowNum,
        dateIso: parsed.dateIso,
        rows: [],
        debitUnits: 0n,
        creditUnits: 0n,
      };
    }
    open.rows.push(parsed);
    open.debitUnits += BigInt(parsed.debitUnits);
    open.creditUnits += BigInt(parsed.creditUnits);

    if (open.debitUnits === open.creditUnits && open.debitUnits > 0n) {
      closeAsEntry(open);
      open = null;
    }
  }

  if (open !== null) {
    const off = open.debitUnits > open.creditUnits
      ? open.debitUnits - open.creditUnits
      : open.creditUnits - open.debitUnits;
    errors.push({
      row: open.startRow,
      message:
        `the rows starting at row ${open.startRow} never balance — debits total ` +
        `${unitsToAmountString(open.debitUnits)} and credits total ` +
        `${unitsToAmountString(open.creditUnits)} (off by ${unitsToAmountString(off)}). ` +
        `"Group consecutive rows until balanced" needs the file to end on a ` +
        `complete entry.`,
    });
  }

  return entries;
}

/**
 * Applies a CONFIRMED journal mapping to raw rows, producing entry groups
 * byte-compatible with the QB parser's JournalEntry (tools/journal-logic.ts)
 * — so tools/lib/journal-load.ts's staging pipeline consumes them unchanged.
 * Per-row data problems are collected into `errors` (English, row-numbered),
 * never thrown; a structurally invalid mapping throws immediately. Blank
 * rows are skipped silently. Balance validation beyond what grouping itself
 * requires stays the database pipeline's job (validate_batch), per CLAUDE.md
 * — this layer's guarantee is only: no row is ever silently dropped.
 */
export function applyJournalMapping(
  rows: string[][],
  mapping: UniversalJournalMapping,
): { entries: JournalEntry[]; errors: RowError[] } {
  checkColIndex("dateCol", mapping.dateCol, true);
  checkColIndex("accountCol", mapping.accountCol, true);
  checkColIndex("memoCol", mapping.memoCol, false);
  checkColIndex("nameCol", mapping.nameCol, false);
  checkColIndex("refCol", mapping.refCol, false);
  if (!DATE_FORMATS.includes(mapping.dateFormat)) {
    throw new Error(`unknown date format "${String(mapping.dateFormat)}".`);
  }
  const shape = mapping.amountShape;
  if (shape.kind === "debit-credit") {
    checkColIndex("debitCol", shape.debitCol, true);
    checkColIndex("creditCol", shape.creditCol, true);
  } else if (shape.kind === "signed") {
    checkColIndex("amountCol", shape.amountCol, true);
    if (shape.positiveIs !== "debit" && shape.positiveIs !== "credit") {
      throw new Error(`positiveIs must be "debit" or "credit".`);
    }
  } else {
    throw new Error(`unknown amount shape "${String((shape as { kind: string }).kind)}".`);
  }
  const grouping = mapping.grouping;
  if (grouping.kind === "key-column") {
    checkColIndex("groupKeyCol", grouping.groupKeyCol, true);
  } else if (grouping.kind !== "consecutive-balanced") {
    throw new Error(`unknown grouping "${String((grouping as { kind: string }).kind)}".`);
  }

  const errors: RowError[] = [];
  const keyed: { parsed: ParsedJournalRow; keyRaw: string }[] = [];
  const sequential: ({ parsed: ParsedJournalRow } | { errorRow: number })[] = [];

  for (let i = mapping.headerRows; i < rows.length; i++) {
    const row = rows[i]!;
    const rowNum = i + 1;
    if (isEmptyRow(row)) continue;

    let parsed: ParsedJournalRow;
    try {
      const dateIso = parseUniversalDate((row[mapping.dateCol] ?? "").trim(), mapping.dateFormat);
      const account = (row[mapping.accountCol] ?? "").trim();
      if (account === "") {
        throw new Error("account is blank.");
      }
      const { debitUnits, creditUnits } = parseRowAmounts(row, shape);
      parsed = {
        rowNum,
        dateIso,
        account,
        debitUnits,
        creditUnits,
        memo: mapping.memoCol !== undefined ? (row[mapping.memoCol] ?? "").trim() : "",
        name: mapping.nameCol !== undefined ? (row[mapping.nameCol] ?? "").trim() : "",
        num: mapping.refCol !== undefined ? (row[mapping.refCol] ?? "").trim() : "",
      };
    } catch (e) {
      errors.push({ row: rowNum, message: (e as Error).message });
      if (grouping.kind === "consecutive-balanced") {
        sequential.push({ errorRow: rowNum });
      }
      continue;
    }

    if (grouping.kind === "key-column") {
      keyed.push({ parsed, keyRaw: row[grouping.groupKeyCol] ?? "" });
    } else {
      sequential.push({ parsed });
    }
  }

  const entries =
    grouping.kind === "key-column"
      ? groupByKeyColumn(keyed, errors)
      : groupConsecutiveBalanced(sequential, errors);

  return { entries, errors };
}
