// importFormat — the format codec (#324).
//
// The chantier's root bug was a format field that never reached the database
// and a restore that invented a value for it. These tests hold the two halves
// of the repair: the codec carries EVERY field in both directions, and the
// wizard restores what was saved instead of re-deriving it.

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type {
  ImportFormat,
  ImportFormatRow,
  ImportFormatRowInput,
} from "../shared/types";
import {
  AMOUNT_MODES,
  FORMAT_FIELD_PAIRS,
  ImportFormatError,
  ROW_ERROR_KEYS,
  SIGN_CONVENTIONS,
  clearMappingForMode,
  detectAmountSeparators,
  formatFromRow,
  formatToRow,
  isRowErrorKey,
  mapRow,
} from "./importFormat";

// ---------------------------------------------------------------------------
// Samples. Every field deliberately differs from the wizard's default config,
// so a dropped field surfaces as a mismatch rather than as an accidental pass.
// ---------------------------------------------------------------------------

const SAMPLE_FORMAT: ImportFormat = {
  delimiter: ",",
  encoding: "windows-1252",
  dateFormat: "YYYY-MM-DD",
  skipLines: 3,
  hasHeader: false,
  columnMapping: { date: 2, description: 5, amount: 7 },
  amountMode: "single",
  signConvention: "positive_expense",
};

const SAMPLE_ROW: ImportFormatRow = {
  delimiter: ",",
  encoding: "windows-1252",
  date_format: "YYYY-MM-DD",
  skip_lines: 3,
  has_header: 0,
  column_mapping: '{"date":2,"description":5,"amount":7}',
  amount_mode: "single",
  sign_convention: "positive_expense",
};

// ---------------------------------------------------------------------------
// Completeness — the property the codec exists to hold
// ---------------------------------------------------------------------------

describe("codec completeness (#324)", () => {
  // `FORMAT_FIELD_PAIRS` is typed `Record<keyof ImportFormat, keyof
  // ImportFormatRow>`, so a field added to `ImportFormat` fails to BUILD until
  // it is listed. These two assertions close the other half: a field listed but
  // not wired through a codec body fails the TEST.
  it("formatToRow emits exactly the persisted fields of the table", () => {
    expect(Object.keys(formatToRow(SAMPLE_FORMAT)).sort()).toEqual(
      Object.values(FORMAT_FIELD_PAIRS).sort()
    );
  });

  it("formatFromRow emits exactly the domain fields of the table", () => {
    expect(Object.keys(formatFromRow(SAMPLE_ROW)).sort()).toEqual(
      Object.keys(FORMAT_FIELD_PAIRS).sort()
    );
  });

  it("covers the eight format fields, no more and no fewer", () => {
    expect(Object.keys(FORMAT_FIELD_PAIRS)).toHaveLength(8);
  });

  it("maps every domain field to a distinct persisted field", () => {
    const persisted = Object.values(FORMAT_FIELD_PAIRS);
    expect(new Set(persisted).size).toBe(persisted.length);
  });
});

// ---------------------------------------------------------------------------
// Round trip — configure -> save -> reload
// ---------------------------------------------------------------------------

describe("round trip (#324)", () => {
  it("survives domain -> row -> domain field by field", () => {
    const restored = formatFromRow(formatToRow(SAMPLE_FORMAT));
    for (const field of Object.keys(FORMAT_FIELD_PAIRS) as Array<
      keyof ImportFormat
    >) {
      expect(restored[field]).toEqual(SAMPLE_FORMAT[field]);
    }
    expect(restored).toEqual(SAMPLE_FORMAT);
  });

  it("survives row -> domain -> row field by field", () => {
    expect(formatToRow(formatFromRow(SAMPLE_ROW))).toEqual(SAMPLE_ROW);
  });

  // THE regression: the credit-card case. Before v17 the convention was not a
  // column, so restoring one wrote "negative_expense" and every expense of the
  // next import landed as income.
  it("keeps positive_expense across a save and a reload", () => {
    const positive: ImportFormat = {
      ...SAMPLE_FORMAT,
      signConvention: "positive_expense",
    };
    expect(formatFromRow(formatToRow(positive)).signConvention).toBe(
      "positive_expense"
    );
  });

  it("keeps negative_expense across a save and a reload", () => {
    const negative: ImportFormat = {
      ...SAMPLE_FORMAT,
      signConvention: "negative_expense",
    };
    expect(formatFromRow(formatToRow(negative)).signConvention).toBe(
      "negative_expense"
    );
  });

  // The mode used to be re-derived from `mapping.debitAmount !== undefined`. A
  // debit/credit source whose debit column is not mapped yet came back as
  // "single" — this is that shape.
  it("keeps debit_credit even when the mapping carries no debit column", () => {
    const partial: ImportFormat = {
      ...SAMPLE_FORMAT,
      amountMode: "debit_credit",
      columnMapping: { date: 0, description: 1, creditAmount: 3 },
    };
    const restored = formatFromRow(formatToRow(partial));
    expect(restored.amountMode).toBe("debit_credit");
    expect(restored.columnMapping).toEqual({
      date: 0,
      description: 1,
      creditAmount: 3,
    });
  });

  it("keeps single even when the mapping still carries debit/credit keys", () => {
    const stale: ImportFormat = {
      ...SAMPLE_FORMAT,
      amountMode: "single",
      columnMapping: { date: 0, description: 1, amount: 2, debitAmount: 3 },
    };
    expect(formatFromRow(formatToRow(stale)).amountMode).toBe("single");
  });
});

// ---------------------------------------------------------------------------
// has_header normalization
// ---------------------------------------------------------------------------

describe("has_header normalization", () => {
  it("writes 0/1, never a boolean — SQLite has no boolean type", () => {
    expect(formatToRow({ ...SAMPLE_FORMAT, hasHeader: true }).has_header).toBe(1);
    expect(formatToRow({ ...SAMPLE_FORMAT, hasHeader: false }).has_header).toBe(0);
  });

  it("reads the integer an import_config_templates row carries", () => {
    expect(formatFromRow({ ...SAMPLE_ROW, has_header: 1 }).hasHeader).toBe(true);
    expect(formatFromRow({ ...SAMPLE_ROW, has_header: 0 }).hasHeader).toBe(false);
  });

  it("reads the boolean an import_sources row is declared with", () => {
    const asBoolean: ImportFormatRowInput = { ...SAMPLE_ROW, has_header: true };
    expect(formatFromRow(asBoolean).hasHeader).toBe(true);
    expect(formatFromRow({ ...asBoolean, has_header: false }).hasHeader).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// Validation — a value the app cannot map must raise, never fall back
// ---------------------------------------------------------------------------

describe("formatFromRow validation", () => {
  it("accepts every mode and convention the app implements", () => {
    for (const amount_mode of AMOUNT_MODES) {
      expect(formatFromRow({ ...SAMPLE_ROW, amount_mode }).amountMode).toBe(
        amount_mode
      );
    }
    for (const sign_convention of SIGN_CONVENTIONS) {
      expect(
        formatFromRow({ ...SAMPLE_ROW, sign_convention }).signConvention
      ).toBe(sign_convention);
    }
  });

  // The v17 CHECK admits 'absolute_indicator' so the third mode ships without a
  // migration. Until it is implemented, reading one must raise — a fall-back to
  // the `single` branch would read the wrong column for every row.
  it("rejects an amount mode the app cannot map", () => {
    const row = {
      ...SAMPLE_ROW,
      amount_mode: "absolute_indicator",
    } as unknown as ImportFormatRowInput;
    expect(() => formatFromRow(row)).toThrow(ImportFormatError);
    try {
      formatFromRow(row);
    } catch (e) {
      expect((e as ImportFormatError).i18nKey).toBe(
        "import.errors.unsupportedAmountMode"
      );
    }
  });

  it("rejects an unknown sign convention rather than defaulting", () => {
    const row = {
      ...SAMPLE_ROW,
      sign_convention: "whatever",
    } as unknown as ImportFormatRowInput;
    expect(() => formatFromRow(row)).toThrow(ImportFormatError);
    try {
      formatFromRow(row);
    } catch (e) {
      expect((e as ImportFormatError).i18nKey).toBe(
        "import.errors.unsupportedSignConvention"
      );
    }
  });

  it("rejects a column mapping that is not valid JSON", () => {
    expect(() =>
      formatFromRow({ ...SAMPLE_ROW, column_mapping: "{not json" })
    ).toThrow(ImportFormatError);
  });

  it("rejects a column mapping without usable date/description columns", () => {
    for (const column_mapping of ["null", "[]", '{"date":"2"}', "{}"]) {
      expect(() => formatFromRow({ ...SAMPLE_ROW, column_mapping })).toThrow(
        ImportFormatError
      );
    }
  });
});

// ---------------------------------------------------------------------------
// clearMappingForMode — the mode owns the mapping
// ---------------------------------------------------------------------------

describe("clearMappingForMode", () => {
  const full = {
    date: 0,
    description: 1,
    amount: 2,
    debitAmount: 3,
    creditAmount: 4,
  };

  it("drops the debit/credit columns when switching to single", () => {
    expect(clearMappingForMode(full, "single")).toEqual({
      date: 0,
      description: 1,
      amount: 2,
    });
  });

  it("drops the amount column when switching to debit/credit", () => {
    expect(clearMappingForMode(full, "debit_credit")).toEqual({
      date: 0,
      description: 1,
      debitAmount: 3,
      creditAmount: 4,
    });
  });

  it("keeps date and description untouched in both directions", () => {
    expect(clearMappingForMode({ date: 4, description: 6 }, "single")).toEqual({
      date: 4,
      description: 6,
    });
    expect(
      clearMappingForMode({ date: 4, description: 6 }, "debit_credit")
    ).toEqual({ date: 4, description: 6 });
  });

  // #325 replaces the `?? 0` fallbacks with an explicit "amount column not
  // mapped" row error. Materializing the column the <select> merely DISPLAYS
  // would make that error unreachable, so an absent column stays absent.
  it("does not invent the column the select displays by default", () => {
    const cleared = clearMappingForMode({ date: 0, description: 1 }, "single");
    expect("amount" in cleared).toBe(false);
    const pair = clearMappingForMode({ date: 0, description: 1 }, "debit_credit");
    expect("debitAmount" in pair).toBe(false);
    expect("creditAmount" in pair).toBe(false);
  });

  it("emits no undefined-valued keys, so the persisted JSON stays clean", () => {
    const row = formatToRow({
      ...SAMPLE_FORMAT,
      columnMapping: clearMappingForMode(full, "single"),
    });
    expect(row.column_mapping).toBe('{"date":0,"description":1,"amount":2}');
  });

  it("is idempotent", () => {
    const once = clearMappingForMode(full, "debit_credit");
    expect(clearMappingForMode(once, "debit_credit")).toEqual(once);
  });
});

// ---------------------------------------------------------------------------
// mapRow — the row rule, lifted out of the wizard (#325)
// ---------------------------------------------------------------------------

const DC_FORMAT: ImportFormat = {
  delimiter: ";",
  encoding: "utf-8",
  dateFormat: "DD/MM/YYYY",
  skipLines: 0,
  hasHeader: true,
  columnMapping: { date: 0, description: 1, debitAmount: 2, creditAmount: 3 },
  amountMode: "debit_credit",
  signConvention: "negative_expense",
};

const SINGLE_FORMAT: ImportFormat = {
  ...DC_FORMAT,
  columnMapping: { date: 0, description: 1, amount: 2 },
  amountMode: "single",
};

/** The amount of a row that parsed, or the error key of one that did not. */
function outcome(row: ReturnType<typeof mapRow>): number | string {
  return row.parsed ? row.parsed.amount : row.error!;
}

describe("mapRow — debit/credit is a subtraction, not a nullity test (#325)", () => {
  it("reads a debit as negative and a credit as positive", () => {
    expect(
      outcome(mapRow(["05/01/2025", "EPICERIE", "84,32", ""], DC_FORMAT))
    ).toBe(-84.32);
    expect(
      outcome(mapRow(["15/01/2025", "PAIE", "", "1250,00"], DC_FORMAT))
    ).toBe(1250);
  });

  it("treats a 0,00 cell in the unused column as absent", () => {
    // The bug: `isNaN(credit)` was false for "0,00", so the credit won and
    // every debit imported as zero.
    expect(
      outcome(mapRow(["05/01/2025", "EPICERIE", "84,32", "0,00"], DC_FORMAT))
    ).toBe(-84.32);
    expect(
      outcome(mapRow(["15/01/2025", "PAIE", "0,00", "1250,00"], DC_FORMAT))
    ).toBe(1250);
  });

  it("takes both columns as magnitudes, whatever sign they carry", () => {
    expect(
      outcome(mapRow(["05/01/2025", "EPICERIE", "-84,32", ""], DC_FORMAT))
    ).toBe(-84.32);
    expect(
      outcome(mapRow(["15/01/2025", "PAIE", "", "-1250,00"], DC_FORMAT))
    ).toBe(1250);
  });

  it("nets a row that fills both columns", () => {
    expect(
      outcome(mapRow(["05/01/2025", "AJUST", "40,00", "100,00"], DC_FORMAT))
    ).toBe(60);
  });

  it("errors when NEITHER column is readable, instead of importing 0", () => {
    expect(outcome(mapRow(["05/01/2025", "X", "", ""], DC_FORMAT))).toBe(
      ROW_ERROR_KEYS.invalidAmount
    );
    expect(outcome(mapRow(["05/01/2025", "X", "n/a", "-"], DC_FORMAT))).toBe(
      ROW_ERROR_KEYS.invalidAmount
    );
  });

  it("ignores the sign convention, which only applies to a single column", () => {
    const flipped: ImportFormat = {
      ...DC_FORMAT,
      signConvention: "positive_expense",
    };
    expect(
      outcome(mapRow(["05/01/2025", "EPICERIE", "84,32", ""], flipped))
    ).toBe(-84.32);
  });
});

describe("mapRow — an unmapped amount column is an error, not column 0 (#325)", () => {
  // The `?? 0` fallbacks read column 0 — usually the date — for every row.

  it("refuses a single-amount format with no amount column", () => {
    const { amount: _drop, ...mapping } = SINGLE_FORMAT.columnMapping;
    const row = mapRow(["05/01/2025", "EPICERIE", "-84,32"], {
      ...SINGLE_FORMAT,
      columnMapping: mapping,
    });
    expect(row.parsed).toBeNull();
    expect(row.error).toBe(ROW_ERROR_KEYS.amountColumnNotMapped);
  });

  it("refuses a debit/credit format with neither column mapped", () => {
    const row = mapRow(["05/01/2025", "EPICERIE", "84,32", ""], {
      ...DC_FORMAT,
      columnMapping: { date: 0, description: 1 },
    });
    expect(row.error).toBe(ROW_ERROR_KEYS.amountColumnNotMapped);
  });

  it("accepts a debit/credit format with only ONE column mapped", () => {
    // Half a pair is a legitimate shape (a card statement with debits only).
    expect(
      outcome(
        mapRow(["05/01/2025", "EPICERIE", "84,32"], {
          ...DC_FORMAT,
          columnMapping: { date: 0, description: 1, debitAmount: 2 },
        })
      )
    ).toBe(-84.32);
  });

  it("reports the format error before any per-row problem", () => {
    const row = mapRow(["not-a-date", "EPICERIE", "-84,32"], {
      ...SINGLE_FORMAT,
      columnMapping: { date: 0, description: 1 },
    });
    expect(row.error).toBe(ROW_ERROR_KEYS.amountColumnNotMapped);
  });
});

describe("mapRow — the rest of the contract (#325)", () => {
  it("keeps the date check ahead of the amount check", () => {
    expect(outcome(mapRow(["nope", "X", "abc"], SINGLE_FORMAT))).toBe(
      ROW_ERROR_KEYS.invalidDate
    );
    expect(outcome(mapRow(["05/01/2025", "X", "abc"], SINGLE_FORMAT))).toBe(
      ROW_ERROR_KEYS.invalidAmount
    );
  });

  it("applies positive_expense to a single amount column", () => {
    expect(
      outcome(
        mapRow(["05/01/2025", "EPICERIE", "84,32"], {
          ...SINGLE_FORMAT,
          signConvention: "positive_expense",
        })
      )
    ).toBe(-84.32);
  });

  it("carries rowIndex, raw and sourceFilename through", () => {
    const raw = ["05/01/2025", "EPICERIE", "-84,32"];
    const row = mapRow(raw, SINGLE_FORMAT, {
      rowIndex: 41,
      sourceFilename: "releve.csv",
    });
    expect(row.rowIndex).toBe(41);
    expect(row.raw).toBe(raw);
    expect(row.sourceFilename).toBe("releve.csv");
    expect(row.parsed).toEqual({
      date: "2025-01-05",
      description: "EPICERIE",
      amount: -84.32,
    });
  });

  it("defaults rowIndex to 0 and omits sourceFilename when not given", () => {
    const row = mapRow(["05/01/2025", "X", "-1,00"], SINGLE_FORMAT);
    expect(row.rowIndex).toBe(0);
    expect("sourceFilename" in row).toBe(false);
  });

  it("reports every failure as an i18n key", () => {
    for (const raw of [
      ["nope", "X", "-1,00"],
      ["05/01/2025", "X", "abc"],
    ]) {
      const row = mapRow(raw, SINGLE_FORMAT);
      expect(isRowErrorKey(row.error!)).toBe(true);
    }
    expect(isRowErrorKey("boom: sqlite is locked")).toBe(false);
  });
});

describe("detectAmountSeparators — the column decides (#325)", () => {
  const rows = [
    ["05/01/2025", "EPICERIE", "1.234", ""],
    ["15/01/2025", "PAIE", "", "84,32"],
  ];

  it("arbitrates each amount column the format declares", () => {
    const seps = detectAmountSeparators(rows, DC_FORMAT);
    // Column 2 holds only the ambiguous "1.234" — no verdict of its own.
    expect(seps.get(2)).toBeUndefined();
    expect(seps.get(3)).toBe(",");
  });

  it("turns an ambiguous cell into the column's reading", () => {
    const french = [
      ["05/01/2025", "A", "1.234"],
      ["15/01/2025", "B", "84,32"],
    ];
    const seps = detectAmountSeparators(french, SINGLE_FORMAT);
    expect(seps.get(2)).toBe(",");
    expect(
      outcome(mapRow(french[0], SINGLE_FORMAT, { decimalSeparators: seps }))
    ).toBe(1234);
    // Without the column verdict, the same cell reads as 1.234.
    expect(outcome(mapRow(french[0], SINGLE_FORMAT))).toBe(1.234);
  });

  it("looks at no column the format does not use", () => {
    expect(detectAmountSeparators(rows, SINGLE_FORMAT).has(3)).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// Static guards on useImportWizard.
//
// The restore path and the config write point live inside `useCallback`s of a
// React hook and the repository has no jsdom (see FilterPanel.test.tsx), so the
// only way to assert on them is to read the source. Same technique as the guard
// link 1 left on `parseFilesInternal` in csvAutoDetect.test.ts.
// ---------------------------------------------------------------------------

const WIZARD_SRC = readFileSync(
  resolve(import.meta.dirname, "..", "hooks", "useImportWizard.ts"),
  "utf-8"
);

/** The body of a `const <name> = useCallback(...)` block, up to the next one. */
function callbackBody(name: string, nextName: string): string {
  const start = WIZARD_SRC.indexOf(`const ${name} =`);
  const end = WIZARD_SRC.indexOf(`const ${nextName} =`, start);
  expect(start).toBeGreaterThan(-1);
  expect(end).toBeGreaterThan(start);
  return WIZARD_SRC.slice(start, end);
}

/**
 * The block of `selectSource` that rebuilds a stored format. Scoped tightly on
 * purpose: the fresh-source arm below it legitimately names `encoding` while
 * auto-detecting one.
 */
function restoreBranch(): string {
  const body = callbackBody("selectSource", "loadHeadersWithConfig");
  const start = body.indexOf("let restored: SourceConfig | null = null;");
  const end = body.indexOf("if (restored) {", start);
  expect(start).toBeGreaterThan(-1);
  expect(end).toBeGreaterThan(start);
  return body.slice(start, end);
}

describe("useImportWizard — restore reads, never re-derives (#324)", () => {
  it("routes the restore through the codec", () => {
    expect(restoreBranch()).toContain("...formatFromRow(existing)");
  });

  it("no longer re-infers the amount mode from the mapping shape", () => {
    expect(WIZARD_SRC).not.toContain("mapping.debitAmount !== undefined");
  });

  // The spread alone is not enough: a literal AFTER it silently wins, which is
  // how `signConvention: "negative_expense"` overrode the stored value. The
  // restore must name no format field of its own.
  it("names no format field of its own in the restore", () => {
    const branch = restoreBranch();
    for (const field of Object.keys(FORMAT_FIELD_PAIRS)) {
      expect(branch, `restore hardcodes ${field}`).not.toContain(`${field}:`);
    }
  });

  it("restores the template provenance instead of blanking it", () => {
    expect(callbackBody("selectSource", "loadHeadersWithConfig")).toContain(
      "existing?.template_id ?? null"
    );
  });
});

describe("useImportWizard — the config write point (#324)", () => {
  it("writes nothing at the duplicate step", () => {
    const body = callbackBody("checkDuplicatesInternal", "checkDuplicates");
    expect(body).not.toContain("createSource(");
    expect(body).not.toContain("updateSource(");
  });

  it("writes the format at import time, through the codec", () => {
    const body = callbackBody("executeImport", "goToStep");
    expect(body).toContain("formatToRow(config)");
    expect(body).toContain("createSource(");
    expect(body).toContain("updateSource(");
  });

  it("keeps a single write point in the whole hook", () => {
    expect(WIZARD_SRC.match(/await createSource\(/g)).toHaveLength(1);
    expect(WIZARD_SRC.match(/await updateSource\(/g)).toHaveLength(1);
  });
});
