// Pure logic for turning a QuickBooks Journal export into per-entry line
// records, and for slicing those entries by year into deterministic,
// idempotent import batches. No I/O here on purpose — everything is
// testable without a database or a real xlsx file.
// See docs/superpowers/plans/2026-07-30-qb-migration-loader.md, Task 2.

import { createHash } from "node:crypto";

export interface JournalLine {
  entryKey: string;
  type: string;
  /** ISO yyyy-mm-dd. Non-empty ONLY on the line that started the entry —
   *  continuation lines leave the sheet's Date cell blank and this stays "". */
  dateIso: string;
  num: string;
  name: string;
  memo: string;
  account: string;
  /** ".toFixed(2)" string, or "" when the cell was blank. */
  debit: string;
  credit: string;
}

export interface JournalEntry {
  entryKey: string;
  /** The entry's date, taken from its first line — the one canonical date
   *  used for year-grouping and for every staging row written for it. */
  dateIso: string;
  lines: JournalLine[];
}

// Journal xlsx, Sheet1: header row 1; columns (1-indexed, matching
// readQbSheet's convention): 2=Trans #, 4=Type, 6=Date, 8=Num, 10=Name,
// 12=Memo, 14=Account, 16=Debit, 18=Credit. Column 1 holds the subtotal
// marker (a single space) that terminates an entry.
const COL = {
  SUBTOTAL_MARKER: 1,
  TRANS_NUM: 2,
  TYPE: 4,
  DATE: 6,
  NUM: 8,
  NAME: 10,
  MEMO: 12,
  ACCOUNT: 14,
  DEBIT: 16,
  CREDIT: 18,
} as const;

const ALL_COLS = Object.values(COL);

/**
 * Parses Journal sheet rows into entries. First line of each entry carries
 * Trans # (column 2, forward-filled onto every continuation line so every
 * returned line always has an entryKey); continuation lines leave it blank.
 * A row with a single space in column 1 is the entry's subtotal row: it is
 * consumed and never becomes a line, and it terminates the entry.
 *
 * Rejects loudly (never guesses):
 *  - a data line appearing with no Trans # and no entry currently open
 *  - a non-Date value in the Date cell of an entry's FIRST line (continuation
 *    lines' Date cells are not examined — QuickBooks leaves them blank)
 *  - a non-numeric value in a Debit/Credit cell
 *  - an unsupported cell object (e.g. a formula with no scalar result) in a
 *    text column
 *  - the report's grand-total row (column 1 trimmed = "TOTAL", terminating
 *    the report) not matching the sum of every parsed entry's debits/credits
 *  - any non-blank row appearing after the grand-total row
 *
 * Fully-blank rows (every cell null/undefined/empty) are skipped wherever
 * they appear, including the one that typically trails the grand total.
 *
 * `sheetRows` follows readQbSheet's 1-indexed convention (see qb-xlsx.ts).
 */
export function parseJournalRows(sheetRows: unknown[][]): JournalEntry[] {
  const entries: JournalEntry[] = [];
  let currentKey: string | null = null;
  let currentDate = "";
  let currentLines: JournalLine[] = [];
  let sawTotal = false;

  function flush(): void {
    if (currentKey !== null) {
      entries.push({ entryKey: currentKey, dateIso: currentDate, lines: currentLines });
    }
    currentKey = null;
    currentDate = "";
    currentLines = [];
  }

  for (let i = 2; i < sheetRows.length; i++) {
    const row = sheetRows[i];
    if (!row || isBlankRow(row)) continue; // skipped wherever they appear

    if (sawTotal) {
      throw new Error(`row ${i}: unexpected content after the grand total row`);
    }

    const marker = row[COL.SUBTOTAL_MARKER];
    const markerText = typeof marker === "string" ? marker.trim() : "";

    if (markerText === "TOTAL") {
      flush(); // defensive: close any still-open entry before the report ends
      verifyGrandTotal(row, i, entries);
      sawTotal = true;
      continue;
    }

    if (typeof marker === "string" && marker.length > 0 && marker.trim() === "") {
      flush();
      continue;
    }

    const transNum = journalText(row[COL.TRANS_NUM], i, "Trans #");
    const isFirstLine = transNum !== "";

    if (isFirstLine) {
      // Defensive: an unterminated prior entry (no subtotal row seen) is
      // closed before the next one opens rather than silently merging lines
      // from two different entries.
      flush();
      currentKey = transNum;
      currentDate = parseEntryDate(row[COL.DATE], i);
    } else if (currentKey === null) {
      throw new Error(
        `row ${i}: data line outside any entry (no Trans # on this row, and no ` +
          `entry is currently open from a previous row)`,
      );
    }

    currentLines.push({
      entryKey: currentKey!,
      type: journalText(row[COL.TYPE], i, "Type"),
      dateIso: isFirstLine ? currentDate : "",
      num: journalText(row[COL.NUM], i, "Num"),
      name: journalText(row[COL.NAME], i, "Name"),
      memo: journalText(row[COL.MEMO], i, "Memo"),
      account: journalText(row[COL.ACCOUNT], i, "Account"),
      debit: journalAmount(row[COL.DEBIT], i, "Debit"),
      credit: journalAmount(row[COL.CREDIT], i, "Credit"),
    });
  }
  flush(); // EOF: close a final entry even without a trailing subtotal row

  return entries;
}

function isBlankRow(row: unknown[]): boolean {
  return ALL_COLS.every((idx) => row[idx] === undefined || row[idx] === null || row[idx] === "");
}

/**
 * A free end-to-end integrity check on the whole parse: the report's grand
 * total must equal the sum of every entry parsed so far. Compared as
 * integer cents (never float accumulation) so no rounding drift can hide a
 * real mismatch or flag a false one.
 */
function verifyGrandTotal(row: unknown[], rowNum: number, entries: JournalEntry[]): void {
  const fileDebitCents = centsFromAmountString(journalAmount(row[COL.DEBIT], rowNum, "Debit"));
  const fileCreditCents = centsFromAmountString(journalAmount(row[COL.CREDIT], rowNum, "Credit"));

  let parsedDebitCents = 0;
  let parsedCreditCents = 0;
  for (const entry of entries) {
    for (const line of entry.lines) {
      parsedDebitCents += centsFromAmountString(line.debit);
      parsedCreditCents += centsFromAmountString(line.credit);
    }
  }

  const mismatches: string[] = [];
  if (fileDebitCents !== parsedDebitCents) {
    mismatches.push(
      `file says ${centsToStr(fileDebitCents)} debits, parsed entries sum to ` +
        `${centsToStr(parsedDebitCents)}`,
    );
  }
  if (fileCreditCents !== parsedCreditCents) {
    mismatches.push(
      `file says ${centsToStr(fileCreditCents)} credits, parsed entries sum to ` +
        `${centsToStr(parsedCreditCents)}`,
    );
  }
  if (mismatches.length > 0) {
    throw new Error(`row ${rowNum}: grand total mismatch: ${mismatches.join("; ")}`);
  }
}

/** Parses a ".toFixed(2)"-shaped amount string ("" counts as 0) into integer
 *  cents via string splitting — never float multiplication, which could
 *  reintroduce the very rounding error this check exists to catch. Exported
 *  so tools/tb-logic.ts's grand-total check can reuse the exact same
 *  integer-cents approach rather than a second, potentially-divergent copy. */
export function centsFromAmountString(amount: string): number {
  if (amount === "") return 0;
  const negative = amount.startsWith("-");
  const unsigned = negative ? amount.slice(1) : amount;
  const [wholePart, fractionPartRaw] = unsigned.split(".");
  const fractionPart = (fractionPartRaw ?? "").padEnd(2, "0").slice(0, 2);
  const cents = Number(wholePart || "0") * 100 + Number(fractionPart || "0");
  return negative ? -cents : cents;
}

export function centsToStr(cents: number): string {
  const negative = cents < 0;
  const abs = Math.abs(cents);
  const whole = Math.floor(abs / 100);
  const fraction = String(abs % 100).padStart(2, "0");
  return `${negative ? "-" : ""}${whole}.${fraction}`;
}

/** Only ever called on an entry's first line — continuation lines' Date
 *  cells are never examined, matching what QuickBooks actually exports. */
function parseEntryDate(value: unknown, rowNum: number): string {
  if (!(value instanceof Date)) {
    throw new Error(
      `row ${rowNum}: entry's first line has a non-Date value in the Date column: ` +
        describeCellType(value),
    );
  }
  const y = value.getUTCFullYear();
  const m = String(value.getUTCMonth() + 1).padStart(2, "0");
  const d = String(value.getUTCDate()).padStart(2, "0");
  return `${y}-${m}-${d}`;
}

/**
 * Converts a Debit/Credit cell to a ".toFixed(2)" string. Blank cells become
 * "". Anything that isn't a plain number is rejected loudly rather than
 * coerced — QuickBooks amount cells should never be text/formula/rich-text.
 */
export function journalAmount(value: unknown, rowNum: number, columnLabel: string): string {
  if (value === undefined || value === null || value === "") return "";
  if (typeof value === "number") return value.toFixed(2);
  throw new Error(
    `row ${rowNum}: non-numeric value in ${columnLabel} column: ${describeCellType(value)}`,
  );
}

/**
 * Extracts a trimmed string from a Journal text-column cell (Type, Num,
 * Name, Memo, Account). Mirrors chart-logic's cellText: rich-text runs and
 * formula cells with a scalar result are handled; anything else throws a
 * loud, named error rather than silently stringifying to "[object Object]".
 */
export function journalText(value: unknown, rowNum: number, columnLabel: string): string {
  if (value === undefined || value === null) return "";
  if (typeof value === "string") return value.trim();
  if (typeof value === "number" || value instanceof Date) return String(value).trim();

  if (typeof value === "object") {
    const obj = value as Record<string, unknown>;

    if (Array.isArray(obj["richText"])) {
      return (obj["richText"] as Array<{ text?: unknown }>)
        .map((run) => (typeof run?.text === "string" ? run.text : ""))
        .join("")
        .trim();
    }

    if ("formula" in obj || "sharedFormula" in obj) {
      const result = obj["result"];
      if (typeof result === "string" || typeof result === "number") {
        return String(result).trim();
      }
      throw new Error(
        `row ${rowNum}: unsupported cell value in ${columnLabel} column: formula cell ` +
          `without a usable string/number result (got ${describeCellType(result)})`,
      );
    }

    throw new Error(
      `row ${rowNum}: unsupported cell value in ${columnLabel} column: ${describeCellType(value)}`,
    );
  }

  throw new Error(
    `row ${rowNum}: unsupported cell value in ${columnLabel} column: ${describeCellType(value)}`,
  );
}

function describeCellType(value: unknown): string {
  if (value === undefined) return "undefined";
  if (value === null) return "null";
  if (typeof value !== "object") return typeof value;
  const keys = Object.keys(value as object);
  return `object {${keys.join(", ")}}`;
}

/** Groups entries by the year of the ENTRY's own date (all of an entry's
 *  lines travel together — lines have no date of their own that matters). */
export function groupEntriesByYear(entries: JournalEntry[]): Map<number, JournalEntry[]> {
  const map = new Map<number, JournalEntry[]>();
  for (const entry of entries) {
    const year = Number(entry.dateIso.slice(0, 4));
    const list = map.get(year);
    if (list) list.push(entry);
    else map.set(year, [entry]);
  }
  return map;
}

/**
 * Deterministic per-year batch identity: sha256 of
 * `<filename>#<year>#<JSON of the slice's entries>`. Same filename/year/
 * entries always produces the same sha (so a re-run of an unchanged year is
 * recognized as "already loaded"); a different year or different entries
 * always produces a different sha.
 */
export function sliceSha(filename: string, year: number, entries: JournalEntry[]): string {
  const payload = `${filename}#${year}#${JSON.stringify(entries)}`;
  return createHash("sha256").update(payload).digest("hex");
}
