/**
 * The import format codec — the SINGLE conversion point between the persisted
 * shape of a CSV format (`ImportFormatRow`, shared by `import_sources` and
 * `import_config_templates`) and its domain shape (`ImportFormat`).
 *
 * Why a codec and not a shared type: the two shapes cannot be unified. The
 * persisted rows are snake_case, store the column mapping as JSON, and have no
 * boolean type; the wizard works in camelCase on a parsed mapping. See the
 * comment block on `ImportFormatRow` in `shared/types`.
 *
 * The property this file exists to hold: a field cannot be added to the format
 * and then silently skipped on the way to or from the database. That is
 * enforced on two levels — `FORMAT_FIELD_PAIRS` is typed against
 * `keyof ImportFormat`, so the build breaks until a new field is listed, and
 * `importFormat.test.ts` compares the codec's actual output keys against that
 * table, so listing a field without wiring it fails the test. Losing
 * `sign_convention` on the way out is what this chantier is repairing (#324).
 */

import type {
  AmountMode,
  ColumnMapping,
  ImportFormat,
  ImportFormatRow,
  ImportFormatRowInput,
  ParsedRow,
  SignConvention,
} from "../shared/types";
import {
  detectDecimalSeparator,
  parseFrenchAmount,
  type DecimalSeparator,
} from "./amountParser";
import { parseDate } from "./dateParser";

/**
 * Values the application can actually map. The database `CHECK` on
 * `import_sources.amount_mode` deliberately admits `absolute_indicator` so the
 * third mode ships without another migration — but until it is implemented,
 * reading one must be a visible error, never a silent fall-through to the
 * `single` branch (which would read the wrong column for every row).
 */
export const AMOUNT_MODES: readonly AmountMode[] = ["single", "debit_credit"];
export const SIGN_CONVENTIONS: readonly SignConvention[] = [
  "negative_expense",
  "positive_expense",
];

/**
 * The field correspondence table, domain key -> persisted key.
 *
 * Typed as `Record<keyof ImportFormat, keyof ImportFormatRow>`: adding a field
 * to `ImportFormat` makes this literal stop satisfying the mapped type and the
 * build fails until the field is listed here. The test then verifies that both
 * codec directions actually emit every listed field.
 */
export const FORMAT_FIELD_PAIRS: Record<keyof ImportFormat, keyof ImportFormatRow> = {
  delimiter: "delimiter",
  encoding: "encoding",
  dateFormat: "date_format",
  skipLines: "skip_lines",
  hasHeader: "has_header",
  columnMapping: "column_mapping",
  amountMode: "amount_mode",
  signConvention: "sign_convention",
};

/** i18n keys for the ways a stored format can be unreadable. */
export type ImportFormatErrorKey =
  | "import.errors.unsupportedAmountMode"
  | "import.errors.unsupportedSignConvention"
  | "import.errors.invalidColumnMapping";

/**
 * Thrown when a persisted format cannot be decoded. It carries an i18n key so
 * the wizard can surface it verbatim instead of falling back to a default
 * format — a wrong default is precisely how the original bug wrote reversed
 * amounts without an error.
 */
export class ImportFormatError extends Error {
  readonly i18nKey: ImportFormatErrorKey;

  constructor(i18nKey: ImportFormatErrorKey, detail: string) {
    super(`${i18nKey}: ${detail}`);
    this.name = "ImportFormatError";
    this.i18nKey = i18nKey;
  }
}

/** Domain format -> persisted row. `has_header` is normalized to 0/1 here. */
export function formatToRow(format: ImportFormat): ImportFormatRow {
  return {
    delimiter: format.delimiter,
    encoding: format.encoding,
    date_format: format.dateFormat,
    skip_lines: format.skipLines,
    has_header: format.hasHeader ? 1 : 0,
    column_mapping: JSON.stringify(format.columnMapping),
    amount_mode: format.amountMode,
    sign_convention: format.signConvention,
  };
}

/**
 * Persisted row -> domain format.
 *
 * Every enumerated field is validated against the application's whitelist and
 * an unknown value raises rather than falls back, because both fall-backs are
 * silent data corruption: an unmapped `amount_mode` reads the wrong column, and
 * anything other than `positive_expense` would mean `negative_expense` and flip
 * every sign.
 */
export function formatFromRow(row: ImportFormatRowInput): ImportFormat {
  if (!AMOUNT_MODES.includes(row.amount_mode)) {
    throw new ImportFormatError(
      "import.errors.unsupportedAmountMode",
      String(row.amount_mode)
    );
  }
  if (!SIGN_CONVENTIONS.includes(row.sign_convention)) {
    throw new ImportFormatError(
      "import.errors.unsupportedSignConvention",
      String(row.sign_convention)
    );
  }

  let columnMapping: ColumnMapping;
  try {
    columnMapping = JSON.parse(row.column_mapping) as ColumnMapping;
  } catch {
    throw new ImportFormatError(
      "import.errors.invalidColumnMapping",
      row.column_mapping
    );
  }
  if (
    columnMapping === null ||
    typeof columnMapping !== "object" ||
    typeof columnMapping.date !== "number" ||
    typeof columnMapping.description !== "number"
  ) {
    throw new ImportFormatError(
      "import.errors.invalidColumnMapping",
      row.column_mapping
    );
  }

  return {
    delimiter: row.delimiter,
    encoding: row.encoding,
    dateFormat: row.date_format,
    skipLines: row.skip_lines,
    hasHeader: !!row.has_header,
    columnMapping,
    amountMode: row.amount_mode,
    signConvention: row.sign_convention,
  };
}

/**
 * Drop the columns belonging to the mode being left behind, so the amount mode
 * is the source of truth for the mapping rather than the reverse.
 *
 * Before v17 the mode was re-inferred from `mapping.debitAmount !== undefined`,
 * which made a stale key from an abandoned mode silently decide how amounts
 * were read. The mode is persisted now, but a mapping still carrying both
 * shapes would keep the two disagreeing — so switching mode prunes.
 *
 * The column the `<select>` merely DISPLAYS by default (`mapping.amount ?? 0`)
 * is deliberately not materialized: #325 turns an unmapped amount column into
 * an explicit row error, and writing a 0 here would make that error
 * unreachable.
 */
export function clearMappingForMode(
  mapping: ColumnMapping,
  mode: AmountMode
): ColumnMapping {
  const base = { date: mapping.date, description: mapping.description };
  if (mode === "debit_credit") {
    return {
      ...base,
      ...(mapping.debitAmount !== undefined
        ? { debitAmount: mapping.debitAmount }
        : {}),
      ...(mapping.creditAmount !== undefined
        ? { creditAmount: mapping.creditAmount }
        : {}),
    };
  }
  return {
    ...base,
    ...(mapping.amount !== undefined ? { amount: mapping.amount } : {}),
  };
}

// ---------------------------------------------------------------------------
// Row mapping — one CSV row + one format -> one `ParsedRow` (#325)
// ---------------------------------------------------------------------------

/**
 * i18n keys for the ways a single row can fail. They used to be raw English
 * literals rendered straight into the preview table; they are keys now so the
 * strings live in the locale files like every other displayed text. Resolve
 * them with `isRowErrorKey` before calling `t()` — an `ImportReport` also
 * carries raw exception messages, which must never be fed to the translator.
 */
export const ROW_ERROR_KEYS = {
  invalidDate: "import.rowErrors.invalidDate",
  invalidAmount: "import.rowErrors.invalidAmount",
  amountColumnNotMapped: "import.rowErrors.amountColumnNotMapped",
  parseError: "import.rowErrors.parseError",
} as const;

export type RowErrorKey = (typeof ROW_ERROR_KEYS)[keyof typeof ROW_ERROR_KEYS];

const ROW_ERROR_VALUES: readonly string[] = Object.values(ROW_ERROR_KEYS);

/** True when the string is one of ours and can safely be translated. */
export function isRowErrorKey(value: string): value is RowErrorKey {
  return ROW_ERROR_VALUES.includes(value);
}

export interface MapRowOptions {
  /** Position of the row in the whole import. Defaults to 0. */
  rowIndex?: number;
  /** File the row came from, carried through to the report. */
  sourceFilename?: string;
  /**
   * Decimal separator arbitrated at COLUMN level, keyed by column index — see
   * `detectAmountSeparators`. A column absent from the map keeps the per-cell
   * default.
   */
  decimalSeparators?: ReadonlyMap<number, DecimalSeparator>;
}

/**
 * Map one raw CSV row to a `ParsedRow` under a given format. PURE: no React, no
 * I/O, no throw — every failure comes back as `error` on the row.
 *
 * This rule used to live inside a `useCallback` of `useImportWizard`, which is
 * why the corpus tests had to keep a hand-written mirror of it and why the
 * detection score (#328) and the signed preview (#329) would each have had to
 * re-implement it. One implementation, three consumers.
 *
 * The two-column rule is `credit - debit` on MAGNITUDES. It used to be
 * `isNaN(credit) ? -debit : credit`, so a bank writing `0,00` in the unused
 * column — which is common — made every debit import as 0,00 and pass
 * validation, because `isNaN(0)` is false. Subtraction needs no special case
 * for a `0,00` cell: zero is the identity. `Math.abs` implements the documented
 * convention (both columns hold positive numbers) even when an export negates
 * its debits.
 *
 * A row whose amount is unreadable in BOTH columns is an error, never a 0: an
 * amount nobody could read must not enter the ledger as a free transaction.
 */
export function mapRow(
  raw: string[],
  format: ImportFormat,
  options: MapRowOptions = {}
): ParsedRow {
  const mapping = format.columnMapping;
  const base = {
    rowIndex: options.rowIndex ?? 0,
    raw,
    ...(options.sourceFilename !== undefined
      ? { sourceFilename: options.sourceFilename }
      : {}),
  };
  const fail = (error: RowErrorKey): ParsedRow => ({
    ...base,
    parsed: null,
    error,
  });

  const readAmount = (col: number): number =>
    parseFrenchAmount(raw[col]?.trim() ?? "", {
      decimalSeparator: options.decimalSeparators?.get(col),
    });

  // 1. Configuration. An unmapped amount column used to fall back to `?? 0`,
  //    reading column 0 — usually the date — for every row of the file. That is
  //    a format error affecting the whole import, so it is reported before any
  //    per-row problem.
  const debitMapped = mapping.debitAmount !== undefined;
  const creditMapped = mapping.creditAmount !== undefined;
  if (format.amountMode === "debit_credit") {
    if (!debitMapped && !creditMapped) {
      return fail(ROW_ERROR_KEYS.amountColumnNotMapped);
    }
  } else if (mapping.amount === undefined) {
    return fail(ROW_ERROR_KEYS.amountColumnNotMapped);
  }

  // 2. Date. Kept ahead of the amount value, as it has always been.
  const date = parseDate(raw[mapping.date]?.trim() || "", format.dateFormat);
  if (!date) return fail(ROW_ERROR_KEYS.invalidDate);

  // 3. Amount.
  let amount: number;
  if (format.amountMode === "debit_credit") {
    const debit = debitMapped ? readAmount(mapping.debitAmount!) : NaN;
    const credit = creditMapped ? readAmount(mapping.creditAmount!) : NaN;
    if (isNaN(debit) && isNaN(credit)) {
      return fail(ROW_ERROR_KEYS.invalidAmount);
    }
    amount =
      (isNaN(credit) ? 0 : Math.abs(credit)) -
      (isNaN(debit) ? 0 : Math.abs(debit));
  } else {
    amount = readAmount(mapping.amount!);
    if (isNaN(amount)) return fail(ROW_ERROR_KEYS.invalidAmount);
    if (format.signConvention === "positive_expense") amount = -amount;
  }

  return {
    ...base,
    parsed: {
      date,
      description: raw[mapping.description]?.trim() || "",
      amount,
    },
  };
}

/**
 * Arbitrate the decimal separator of every amount column the format declares,
 * over the whole set of data rows.
 *
 * `1.234` is 1234 in a French column and 1.234 in an English one, and no rule
 * applied to that cell ALONE can tell — but a sibling cell reading `84,32`
 * settles it for the column. Detection deliberately stays out of this (it runs
 * before a column is known to be an amount column at all); the verdict is
 * applied where the value actually becomes a transaction.
 */
export function detectAmountSeparators(
  rows: readonly string[][],
  format: ImportFormat
): Map<number, DecimalSeparator> {
  const mapping = format.columnMapping;
  const columns =
    format.amountMode === "debit_credit"
      ? [mapping.debitAmount, mapping.creditAmount]
      : [mapping.amount];

  const result = new Map<number, DecimalSeparator>();
  for (const col of columns) {
    if (col === undefined) continue;
    const verdict = detectDecimalSeparator(
      rows.map((row) => row[col] ?? "")
    );
    if (verdict) result.set(col, verdict);
  }
  return result;
}
