import { describe, it, expect } from "vitest";
import ExcelJS from "exceljs";
import { writeFile, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
  QB_TYPE_LEDGER_TYPE,
  planAccounts,
  parseListingRows,
  computeLeafAliases,
  findDuplicateNames,
  initialBucketCounters,
  cellText,
  type ListingRow,
} from "../../tools/chart-logic.js";
import { readQbSheet, TIPS_SHEET_NAME } from "../../tools/qb-xlsx.js";

// The full mapping table from the plan (CLAUDE.md-adjacent doc):
// docs/superpowers/plans/2026-07-30-qb-migration-loader.md
const EXPECTED_TYPE_MAP: Record<string, string> = {
  Bank: "asset",
  "Accounts Receivable": "asset",
  "Other Current Asset": "asset",
  "Fixed Asset": "asset",
  "Other Asset": "asset",
  "Accounts Payable": "liability",
  "Credit Card": "liability",
  "Other Current Liability": "liability",
  "Long Term Liability": "liability",
  Equity: "equity",
  Income: "income",
  "Other Income": "income",
  "Cost of Goods Sold": "expense",
  Expense: "expense",
  "Other Expense": "expense",
};

describe("QuickBooks type -> ledger type mapping", () => {
  for (const [qbType, ledgerType] of Object.entries(EXPECTED_TYPE_MAP)) {
    it(`maps "${qbType}" -> ${ledgerType}`, () => {
      expect(QB_TYPE_LEDGER_TYPE[qbType]).toBe(ledgerType);
    });
  }

  it("has no extra mappings beyond the plan's table", () => {
    expect(Object.keys(QB_TYPE_LEDGER_TYPE).sort()).toEqual(
      Object.keys(EXPECTED_TYPE_MAP).sort(),
    );
  });

  it("rejects an unknown QuickBooks type instead of guessing", () => {
    const rows: ListingRow[] = [
      { name: "Weird Account", qbType: "Some Made Up Type" },
    ];
    const plan = planAccounts(rows);
    expect(plan.accounts).toHaveLength(0);
    expect(plan.unknownTypes).toEqual([
      { name: "Weird Account", qbType: "Some Made Up Type" },
    ]);
  });

  it("collects EVERY unknown-type offender, not just the first", () => {
    const rows: ListingRow[] = [
      { name: "Good", qbType: "Bank" },
      { name: "Bad One", qbType: "Nonsense A" },
      { name: "Bad Two", qbType: "Nonsense B" },
    ];
    const plan = planAccounts(rows);
    expect(plan.accounts).toHaveLength(1);
    expect(plan.unknownTypes.map((r) => r.name)).toEqual(["Bad One", "Bad Two"]);
  });
});

describe("within-listing duplicate account names (FIX 1)", () => {
  it("detects the same account name appearing twice in one listing", () => {
    const rows: ListingRow[] = [
      { name: "Checking", qbType: "Bank" },
      { name: "AP", qbType: "Accounts Payable" },
      { name: "Checking", qbType: "Bank" }, // duplicate — same file, same name
    ];
    expect(findDuplicateNames(rows)).toEqual(["Checking"]);
  });

  it("reports every duplicated name, not just the first", () => {
    const rows: ListingRow[] = [
      { name: "Checking", qbType: "Bank" },
      { name: "Checking", qbType: "Bank" },
      { name: "AP", qbType: "Accounts Payable" },
      { name: "AP", qbType: "Accounts Payable" },
      { name: "Unique", qbType: "Equity" },
    ];
    expect(findDuplicateNames(rows)).toEqual(["AP", "Checking"]);
  });

  it("reports nothing when every name in the listing is unique", () => {
    const rows: ListingRow[] = [
      { name: "Checking", qbType: "Bank" },
      { name: "AP", qbType: "Accounts Payable" },
    ];
    expect(findDuplicateNames(rows)).toEqual([]);
  });
});

describe("account code generation", () => {
  it("assigns bucket bases in listing order, step 10", () => {
    const rows: ListingRow[] = [
      { name: "Checking", qbType: "Bank" },
      { name: "Savings", qbType: "Other Current Asset" },
      { name: "AP", qbType: "Accounts Payable" },
      { name: "Owner Equity", qbType: "Equity" },
      { name: "Sales", qbType: "Income" },
      { name: "COGS Materials", qbType: "Cost of Goods Sold" },
      { name: "Office Supplies", qbType: "Expense" },
    ];
    const plan = planAccounts(rows);
    const codes = Object.fromEntries(plan.accounts.map((a) => [a.name, a.code]));
    expect(codes["Checking"]).toBe("1000");
    expect(codes["Savings"]).toBe("1010");
    expect(codes["AP"]).toBe("2000");
    expect(codes["Owner Equity"]).toBe("3000");
    expect(codes["Sales"]).toBe("4000");
    // COGS gets its own 5000 bucket even though it maps to ledger type 'expense'
    expect(codes["COGS Materials"]).toBe("5000");
    expect(codes["Office Supplies"]).toBe("6000");
  });

  it("keeps COGS and Expense/Other Expense in separate code buckets", () => {
    const rows: ListingRow[] = [
      { name: "COGS A", qbType: "Cost of Goods Sold" },
      { name: "Expense A", qbType: "Expense" },
      { name: "COGS B", qbType: "Cost of Goods Sold" },
      { name: "Other Expense A", qbType: "Other Expense" },
    ];
    const plan = planAccounts(rows);
    const codes = Object.fromEntries(plan.accounts.map((a) => [a.name, a.code]));
    expect(codes["COGS A"]).toBe("5000");
    expect(codes["COGS B"]).toBe("5010");
    expect(codes["Expense A"]).toBe("6000");
    expect(codes["Other Expense A"]).toBe("6010");
    // and both still map to ledger type 'expense'
    expect(plan.accounts.find((a) => a.name === "COGS A")!.type).toBe("expense");
    expect(plan.accounts.find((a) => a.name === "Expense A")!.type).toBe("expense");
  });

  it("is deterministic: identical input produces identical codes every time", () => {
    const rows: ListingRow[] = [
      { name: "Checking", qbType: "Bank" },
      { name: "Petty Cash", qbType: "Bank" },
      { name: "AP", qbType: "Accounts Payable" },
    ];
    const first = planAccounts(rows).accounts.map((a) => a.code);
    const second = planAccounts(rows).accounts.map((a) => a.code);
    expect(second).toEqual(first);
  });
});

describe("account code collision avoidance across runs (FIX 2)", () => {
  it("initialBucketCounters resumes each bucket after its highest existing code", () => {
    const counters = initialBucketCounters(["1000", "1010", "2000"]);
    expect(counters.get(1000)).toBe(1020); // asset bucket: max 1010 + 10
    expect(counters.get(2000)).toBe(2010); // liability bucket: max 2000 + 10
    expect(counters.get(3000)).toBe(3000); // equity bucket: untouched, starts at base
  });

  it("planAccounts given existing codes {1000, 1010} in the asset bucket assigns 1020 to the next new asset", () => {
    const rows: ListingRow[] = [{ name: "New Savings Account", qbType: "Bank" }];
    const plan = planAccounts(rows, ["1000", "1010"]);
    expect(plan.accounts).toEqual([
      // subtype added in the chart-management milestone (migration 0016).
      { name: "New Savings Account", type: "asset", subtype: "bank", code: "1020", qbType: "Bank" },
    ]);
  });

  it("does not let a gap in another bucket affect an unrelated bucket's counter", () => {
    const rows: ListingRow[] = [
      { name: "New Bank", qbType: "Bank" },
      { name: "New Expense", qbType: "Expense" },
    ];
    const plan = planAccounts(rows, ["1000", "1010", "1020", "6000"]);
    const codes = Object.fromEntries(plan.accounts.map((a) => [a.name, a.code]));
    expect(codes["New Bank"]).toBe("1030");
    expect(codes["New Expense"]).toBe("6010");
  });

  it("ignores non-numeric or out-of-range existing codes rather than crashing", () => {
    const rows: ListingRow[] = [{ name: "New Bank", qbType: "Bank" }];
    const plan = planAccounts(rows, ["not-a-code", "9999999"]);
    expect(plan.accounts[0]!.code).toBe("1000"); // bucket unaffected, starts fresh at base
  });
});

describe("leaf alias computation", () => {
  it("aliases an unambiguous Parent:Leaf name to its leaf", () => {
    const result = computeLeafAliases(["Bank:Checking", "Accounts Payable"]);
    expect(result.aliases).toEqual([
      { leaf: "Checking", accountName: "Bank:Checking" },
    ]);
    expect(result.ambiguous).toEqual([]);
  });

  it("skips an ambiguous leaf shared by two different parents", () => {
    const result = computeLeafAliases([
      "Bank:Checking",
      "Investments:Checking",
      "Accounts Payable",
    ]);
    expect(result.aliases).toEqual([]);
    expect(result.ambiguous).toEqual(["Checking"]);
  });

  it("ignores names with no ':' (top-level accounts have no leaf alias)", () => {
    const result = computeLeafAliases(["Cash", "Accounts Payable"]);
    expect(result.aliases).toEqual([]);
    expect(result.ambiguous).toEqual([]);
  });

  it("handles a name nested more than one level (leaf = text after the LAST colon)", () => {
    const result = computeLeafAliases(["Expense:Auto:Fuel"]);
    expect(result.aliases).toEqual([{ leaf: "Fuel", accountName: "Expense:Auto:Fuel" }]);
  });
});

describe("cell value extraction (FIX 3 — strict, no [object Object])", () => {
  it("passes through plain strings, trimmed", () => {
    expect(cellText("  Bank:Checking  ")).toBe("Bank:Checking");
  });

  it("passes through numbers and Dates via String()", () => {
    expect(cellText(1234)).toBe("1234");
    expect(cellText(new Date("2024-01-01T00:00:00.000Z")))
      .toBe(String(new Date("2024-01-01T00:00:00.000Z")));
  });

  it("treats null/undefined as empty", () => {
    expect(cellText(null)).toBe("");
    expect(cellText(undefined)).toBe("");
  });

  it("concatenates rich-text runs into plain text", () => {
    const richText = { richText: [{ text: "Bank:" }, { text: "Checking", font: { bold: true } }] };
    expect(cellText(richText)).toBe("Bank:Checking");
  });

  it("reads a formula cell's scalar result", () => {
    expect(cellText({ formula: "=A1&B1", result: "Bank:Checking" })).toBe("Bank:Checking");
    expect(cellText({ formula: "=1+1", result: 2 })).toBe("2");
  });

  it("rejects a formula cell with no usable scalar result instead of stringifying it", () => {
    expect(() => cellText({ formula: "=A1", result: { error: "#REF!" } }))
      .toThrow(/unsupported cell value in Account\/Type column/);
  });

  it("rejects an unsupported object instead of producing [object Object]", () => {
    expect(() => cellText({ error: "#DIV/0!" }))
      .toThrow(/unsupported cell value in Account\/Type column/);
  });
});

describe("listing row parsing (columns 3=Account, 5=Type; 1-indexed offsets)", () => {
  // row[3] = column 3 (Account), row[5] = column 5 (Type) — 1-indexed, so
  // every fixture row below needs 6 slots (index 0 unused/padding included).
  function row(name?: string, qbType?: string): unknown[] {
    return [undefined, undefined, undefined, name, undefined, qbType];
  }

  it("skips the header row and blank/malformed rows", () => {
    const sheetRows: unknown[][] = [
      [], // index 0: unused
      row("Account", "Type"), // index 1: header, skipped unconditionally
      row("Bank:Checking", "Bank"),
      row("AP", "Accounts Payable"),
      [], // trailing blank row
      row("", ""), // empty name/type
      row("No Type Here", undefined),
    ];
    const rows = parseListingRows(sheetRows);
    expect(rows).toEqual([
      { name: "Bank:Checking", qbType: "Bank" },
      { name: "AP", qbType: "Accounts Payable" },
    ]);
  });

  it("trims whitespace from account name and type", () => {
    const sheetRows: unknown[][] = [
      [], // index 0: unused
      row("Account", "Type"), // index 1: header
      row("  Bank:Checking  ", "  Bank  "),
    ];
    const rows = parseListingRows(sheetRows);
    expect(rows).toEqual([{ name: "Bank:Checking", qbType: "Bank" }]);
  });

  it("reads a rich-text Account cell as plain concatenated text", () => {
    const sheetRows: unknown[][] = [
      [],
      row("Account", "Type"),
      [undefined, undefined, undefined,
        { richText: [{ text: "Bank:" }, { text: "Checking" }] }, undefined, "Bank"],
    ];
    const rows = parseListingRows(sheetRows);
    expect(rows).toEqual([{ name: "Bank:Checking", qbType: "Bank" }]);
  });

  it("throws loudly instead of silently stringifying an unsupported cell object", () => {
    const sheetRows: unknown[][] = [
      [],
      row("Account", "Type"),
      [undefined, undefined, undefined, { error: "#REF!" }, undefined, "Bank"],
    ];
    expect(() => parseListingRows(sheetRows))
      .toThrow(/unsupported cell value in Account\/Type column/);
  });
});

describe("qb-xlsx helper against a real workbook", () => {
  it("ignores the Tips sheet and reads Sheet1 rows, columns 3 and 5", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "qb-xlsx-test-"));
    const file = path.join(dir, "listing.xlsx");
    try {
      const workbook = new ExcelJS.Workbook();
      const tips = workbook.addWorksheet(TIPS_SHEET_NAME);
      tips.getCell("A1").value = "Ignore me entirely";

      const sheet = workbook.addWorksheet("Sheet1");
      sheet.getCell(1, 3).value = "Account";
      sheet.getCell(1, 5).value = "Type";
      sheet.getCell(2, 3).value = "Bank:Checking";
      sheet.getCell(2, 5).value = "Bank";
      sheet.getCell(3, 3).value = "Accounts Payable";
      sheet.getCell(3, 5).value = "Accounts Payable";

      await workbook.xlsx.writeFile(file);

      const sheetRows = await readQbSheet(file);
      const rows = parseListingRows(sheetRows);
      expect(rows).toEqual([
        { name: "Bank:Checking", qbType: "Bank" },
        { name: "Accounts Payable", qbType: "Accounts Payable" },
      ]);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("rejects a missing file with a clear error", async () => {
    await expect(readQbSheet("does/not/exist.xlsx")).rejects.toThrow();
  });
});
