// Client-side sample checks for the universal import wizard — Switch From
// Anywhere Task 5, review items F1 and F6.
//
// Deliberately a STANDALONE MIRROR of the pieces of tools/universal-map-logic.ts
// it needs, not an import of it — exactly the convention bank/csvMapping.ts
// already set for the statement wizard, and for the same reason: that module
// is the server-authoritative parser bundled for Node, and pulling it into
// the Vite browser bundle would start coupling the two. Keep them in sync by
// hand if either changes.
//
// Nothing here decides anything. The server re-parses the whole file with the
// real engine on every preview/stage, so drift between these two copies can
// only cost a needless question or a missing hint — never a bad import. What
// this file buys is the two things the server round trip CANNOT give us:
//
//  1. sniffDateFormatSample() — the upload reply only sniffs the date format
//     of the column the PRESET guessed. Point the date mapping at a different
//     column and there is no endpoint that re-sniffs it, so without this the
//     format select silently kept its MM/DD/YYYY default — and MM/DD vs DD/MM
//     changes which day every entry posts on while both parse cleanly (zero
//     errors either way). Now an unsniffable/ambiguous column is an explicit
//     unanswered question instead.
//
//  2. summarizeSampleDirections() — the direction check. /journal/preview
//     returns byte-identical counts whether "positive amounts are debits" or
//     "credits" is chosen, so a fully sign-inverted import previews clean,
//     stages clean, posts, and still reports a balanced ledger. Debits equal
//     credits either way; only the SIDES are wrong. This reads the sample
//     rows the upload already returned, applies the mapping as chosen, and
//     says in plain English where a few recognisable accounts land, so a
//     human can see "Product Sales — $8,400.00 reversed" and stop.
//     Sample-based on purpose: it is a sanity prompt, not a verdict.

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",
];

const DAY_AMBIGUOUS_FORMATS: ReadonlySet<string> = new Set([
  "MM/DD/YYYY",
  "M/D/YY",
  "MM-DD-YYYY",
]);

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

function windowTwoDigitYear(yy: number): number {
  return yy >= 70 ? 1900 + yy : 2000 + yy;
}

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

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":
      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:
      return null;
  }
}

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;
}

export interface SampleDateSniff {
  format: UniversalDateFormat | null;
  confident: boolean;
  ambiguousBecause?: string;
}

/**
 * Mirrors the engine's sniffDateFormat over whatever sample values we hold.
 * `confident: false` (or a null format) means the caller must MAKE the user
 * choose — the sample could not prove the answer.
 */
export function sniffDateFormatSample(values: string[]): SampleDateSniff {
  const sample = values.map((v) => v.trim()).filter((v) => v !== "");
  if (sample.length === 0) {
    return { format: null, confident: false, ambiguousBecause: "that column has no dates in the rows shown above." };
  }
  const candidates = DATE_FORMATS.filter((f) => sample.every((v) => tryParseDate(v, f) !== null));
  if (candidates.length === 0) {
    return {
      format: null,
      confident: false,
      ambiguousBecause: `we couldn't read "${sample[0]}" as a date in any format we know.`,
    };
  }
  const format = candidates[0]!;
  if (DAY_AMBIGUOUS_FORMATS.has(format)) {
    const proven = sample.some((v) => {
      const parts = tryParseDate(v, format);
      return parts !== null && parts.day > 12;
    });
    if (!proven) {
      return {
        format,
        confident: false,
        ambiguousBecause:
          "every day-of-month in the rows shown above is 12 or less, so month-first (12/31/2025) " +
          "and day-first (31/12/2025) both fit — only you can say which.",
      };
    }
  }
  return { format, confident: true };
}

// ---------------------------------------------------------------------------
// amounts (mirror of parseUniversalAmount, non-throwing)
// ---------------------------------------------------------------------------

export function isBlankAmountCell(raw: string): boolean {
  const s = raw.trim();
  return s === "" || s === "-" || s === "--";
}

/** Signed cents, or null when the cell isn't an amount we'd accept. */
export function parseAmountCents(raw: string): number | null {
  let s = raw.trim().replace(/\$/g, "").trim();
  if (s === "") return null;
  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();

  if (s.includes(",")) {
    if (!/^\d{1,3}(,\d{3})+(\.\d{1,2})?$/.test(s)) return null;
    s = s.replace(/,/g, "");
  }
  if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) return null;
  const [whole, frac = ""] = s.split(".");
  // The engine refuses more than 11 whole digits (parseUniversalAmount's cap,
  // which keeps its ten-thousandths inside Number's exact-integer range). The
  // mirror has to refuse the same values, or a 12-digit amount reads as fine
  // here and then fails on the server — tests/web/universal-sample.test.ts
  // asserts the two agree on a shared table of cases.
  if (whole !== undefined && whole.length > 11) return null;
  const cents = Number(whole || "0") * 100 + Number(frac.padEnd(2, "0"));
  if (!Number.isSafeInteger(cents)) return null;
  return negative ? -cents : cents;
}

export function formatCents(cents: number): string {
  const abs = Math.abs(cents);
  const whole = Math.floor(abs / 100).toLocaleString("en-US");
  return `$${whole}.${String(abs % 100).padStart(2, "0")}`;
}

// ---------------------------------------------------------------------------
// direction check
// ---------------------------------------------------------------------------

export type LedgerKind = "asset" | "liability" | "equity" | "income" | "expense";

export interface KnownAccount { name: string; type: LedgerKind }

export type AmountShapeChoice =
  | { kind: "debit-credit"; debitCol: number; creditCol: number }
  | { kind: "signed"; amountCol: number; positiveIs: "debit" | "credit" };

/** One recognisable account and what the current mapping does to it. */
export interface SampleDirection {
  account: string;
  /** Plain-English effect, e.g. "$8,400.00 earned" / "$8,400.00 reversed". */
  effect: string;
  /** True when the sample lands on the account's UNUSUAL side — the shape a
   *  sign inversion produces. Purely presentational: real files contain
   *  refunds and payments, so this highlights, it never blocks. */
  unusual: boolean;
}

/** Debit-positive storage: the side each kind normally grows on. */
const DEBIT_NORMAL: Readonly<Record<LedgerKind, boolean>> = {
  asset: true, expense: true, liability: false, equity: false, income: false,
};

/** "increase" / "decrease" phrasing per kind, in the user's words. */
const EFFECT_WORDS: Readonly<Record<LedgerKind, { up: string; down: string }>> = {
  asset: { up: "in", down: "out" },
  expense: { up: "spent", down: "refunded" },
  income: { up: "earned", down: "reversed" },
  liability: { up: "owed", down: "paid down" },
  equity: { up: "put in", down: "taken out" },
};

/**
 * Sums the sample rows per account under the mapping as currently chosen and
 * describes the biggest few in plain English. `lookup` resolves the file's
 * account cell (a name OR a code) to a real account; unresolvable cells are
 * skipped rather than guessed at.
 */
export function summarizeSampleDirections(
  sampleRows: string[][],
  accountCol: number,
  shape: AmountShapeChoice,
  lookup: (raw: string) => KnownAccount | undefined,
  limit = 3,
): SampleDirection[] {
  const totals = new Map<string, { account: KnownAccount; cents: number }>();

  for (const row of sampleRows) {
    const rawAccount = (row[accountCol] ?? "").trim();
    if (rawAccount === "") continue;
    const account = lookup(rawAccount);
    if (account === undefined) continue;

    let signed: number | null = null;
    if (shape.kind === "debit-credit") {
      const debitRaw = row[shape.debitCol] ?? "";
      const creditRaw = row[shape.creditCol] ?? "";
      const debit = isBlankAmountCell(debitRaw) ? 0 : parseAmountCents(debitRaw);
      const credit = isBlankAmountCell(creditRaw) ? 0 : parseAmountCents(creditRaw);
      if (debit === null || credit === null) continue;
      if (debit !== 0 && credit !== 0) continue; // the engine rejects these anyway
      signed = debit !== 0 ? debit : -credit;
    } else {
      const raw = row[shape.amountCol] ?? "";
      if (isBlankAmountCell(raw)) continue;
      const value = parseAmountCents(raw);
      if (value === null || value === 0) continue;
      signed = shape.positiveIs === "debit" ? value : -value;
    }
    if (signed === null || signed === 0) continue;

    const existing = totals.get(account.name);
    if (existing) existing.cents += signed;
    else totals.set(account.name, { account, cents: signed });
  }

  return [...totals.values()]
    .filter((t) => t.cents !== 0)
    .sort((a, b) => Math.abs(b.cents) - Math.abs(a.cents))
    .slice(0, limit)
    .map(({ account, cents }) => {
      const onDebitSide = cents > 0;
      const grows = onDebitSide === DEBIT_NORMAL[account.type];
      const words = EFFECT_WORDS[account.type];
      return {
        account: account.name,
        effect: `${formatCents(cents)} ${grows ? words.up : words.down}`,
        unusual: !grows,
      };
    });
}
