import XLSX from "xlsx";

import { pool } from "../db/pool.js";
import { formatDateOnly } from "../utils/accountingPeriods.js";
import { ApiError } from "../utils/ApiError.js";
import { logAudit } from "../utils/auditLog.js";
import { formatDzdAmount, toNumber } from "../utils/formatters.js";
import { createCompanyNotifications, emitNotifications } from "../utils/notifications.js";

const CASH_DIRECTION_LABELS = {
  credit: "Encaissement",
  debit: "Décaissement",
};

const BANK_DIRECTION_LABELS = {
  credit: "Crédit",
  debit: "Débit",
};

const RECONCILIATION_STATUS_LABELS = {
  open: "Ouvert",
  closed: "Clôturé",
};

const BANK_STATEMENT_REQUIRED_FIELDS = ["date", "label", "debit", "credit"];

const BANK_STATEMENT_FIELD_ALIASES = {
  date: ["date", "date_operation", "dateoperation", "operation_date"],
  operation_number: [
    "operation_number",
    "numero_operation",
    "num_operation",
    "n_operation",
    "reference",
    "numero",
  ],
  label: ["label", "libelle", "description", "motif", "operation_label"],
  debit: ["debit", "debits", "sortie", "withdrawal", "debit_amount"],
  credit: ["credit", "credits", "entree", "deposit", "credit_amount"],
};

const TREASURY_IMPORT_PREVIEW_ROWS = 20;
const CASH_REGISTER_TEMPLATE_HEADERS = ["Nom", "Devise", "Solde initial", "Actif"];
const BANK_ACCOUNT_TEMPLATE_HEADERS = [
  "Banque",
  "N° compte",
  "RIB",
  "Devise",
  "Solde initial",
  "Compte comptable",
  "Actif",
];

const CASH_REGISTER_IMPORT_ALIASES = {
  name: ["nom", "name", "caisse", "cash_register", "designation"],
  currency: ["devise", "currency"],
  current_balance: ["solde_initial", "current_balance", "opening_balance", "solde"],
  is_active: ["actif", "is_active", "active", "statut"],
};

const BANK_ACCOUNT_IMPORT_ALIASES = {
  bank_name: ["banque", "bank_name", "bank", "nom_banque"],
  account_number: ["n_compte", "no_compte", "numero_compte", "account_number", "compte"],
  rib: ["rib", "iban", "reference_bancaire"],
  currency: ["devise", "currency"],
  current_balance: ["solde_initial", "current_balance", "opening_balance", "solde"],
  account_code: ["compte_comptable", "account_code", "compte_lie", "compte_scf"],
  is_active: ["actif", "is_active", "active", "statut"],
};

const cleanString = (value) => String(value ?? "").trim();

const normalizeKey = (value) =>
  cleanString(value)
    .normalize("NFD")
    .replace(/\p{Diacritic}/gu, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "");

const parseDelimitedLine = (line, delimiter) => {
  const cells = [];
  let current = "";
  let inQuotes = false;

  for (let index = 0; index < line.length; index += 1) {
    const character = line[index];
    const nextCharacter = line[index + 1];

    if (character === '"') {
      if (inQuotes && nextCharacter === '"') {
        current += '"';
        index += 1;
      } else {
        inQuotes = !inQuotes;
      }

      continue;
    }

    if (character === delimiter && !inQuotes) {
      cells.push(current);
      current = "";
      continue;
    }

    current += character;
  }

  cells.push(current);

  return cells;
};

const readCsvSpreadsheet = (file) => {
  const textContent = file.buffer.toString("utf8").replace(/^\uFEFF/, "");
  const lines = textContent
    .split(/\r?\n/)
    .map((line) => line.replace(/\r$/, ""))
    .filter((line) => line.trim().length > 0);

  if (!lines.length) {
    throw new ApiError(400, "Le fichier importé est vide.");
  }

  const headerLine = lines[0];
  const delimiter = headerLine.includes(";") ? ";" : ",";
  const headers = parseDelimitedLine(headerLine, delimiter).map(
    (column, index) => cleanString(column) || `column_${index + 1}`,
  );
  const rows = lines.slice(1).map((line, rowIndex) => {
    const values = parseDelimitedLine(line, delimiter);

    return headers.reduce(
      (accumulator, columnName, columnIndex) => {
        accumulator[columnName] = values[columnIndex] ?? "";
        accumulator.__row_number = rowIndex + 2;
        return accumulator;
      },
      { __row_number: rowIndex + 2 },
    );
  });

  return {
    headers,
    rows,
  };
};

const getCompanyId = (req, source = req.query) => {
  const companyId = Number(source.company_id || req.user?.companyId || 0);

  if (!companyId) {
    throw new ApiError(400, "company_id is required.");
  }

  return companyId;
};

const parseDateOnly = (value, fieldLabel = "Date") => {
  const normalized = cleanString(value);

  if (!normalized || !/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
    throw new ApiError(400, `${fieldLabel} invalide.`);
  }

  const date = new Date(`${normalized}T00:00:00Z`);
  const [year, month, day] = normalized.split("-").map(Number);

  if (
    Number.isNaN(date.getTime()) ||
    date.getUTCFullYear() !== year ||
    date.getUTCMonth() + 1 !== month ||
    date.getUTCDate() !== day
  ) {
    throw new ApiError(400, `${fieldLabel} invalide.`);
  }

  return normalized;
};

const parseFlexibleDate = (value, fieldLabel = "Date") => {
  const normalized = cleanString(value);

  if (!normalized) {
    throw new ApiError(400, `${fieldLabel} invalide.`);
  }

  if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
    return parseDateOnly(normalized, fieldLabel);
  }

  const slashMatch = normalized.match(/^(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})$/);

  if (!slashMatch) {
    throw new ApiError(400, `${fieldLabel} invalide.`);
  }

  const first = Number(slashMatch[1]);
  const second = Number(slashMatch[2]);
  const rawYear = Number(slashMatch[3]);
  const year = rawYear < 100 ? 2000 + rawYear : rawYear;
  const day = first > 12 ? first : second;
  const month = first > 12 ? second : first;

  return parseDateOnly(
    `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
    fieldLabel,
  );
};

const parseAmount = (value, fieldLabel = "Montant") => {
  if (value === null || value === undefined || value === "") {
    return 0;
  }

  if (typeof value === "number") {
    if (!Number.isFinite(value) || value < 0) {
      throw new ApiError(400, `${fieldLabel} invalide.`);
    }

    return Number(value.toFixed(2));
  }

  const normalized = cleanString(value).replace(/\s+/g, "").replace(",", ".");
  const parsed = Number.parseFloat(normalized);

  if (!Number.isFinite(parsed) || parsed < 0) {
    throw new ApiError(400, `${fieldLabel} invalide.`);
  }

  return Number(parsed.toFixed(2));
};

const parseBoolean = (value, fallback = null) => {
  if (typeof value === "boolean") {
    return value;
  }

  if (value === null || value === undefined || value === "") {
    return fallback;
  }

  const normalized = cleanString(value).toLowerCase();

  if (["true", "1", "yes", "oui", "actif", "active"].includes(normalized)) {
    return true;
  }

  if (["false", "0", "no", "non", "inactif", "inactive"].includes(normalized)) {
    return false;
  }

  return fallback;
};

const normalizeMovementDirection = (value, { mode = "cash", required = true } = {}) => {
  const normalized = cleanString(value).toLowerCase();

  if (!normalized) {
    if (!required) {
      return "";
    }

    throw new ApiError(400, "Le sens du mouvement est obligatoire.");
  }

  if (["credit", "encaissement", "entree", "entrée", "in", "incoming"].includes(normalized)) {
    return "credit";
  }

  if (["debit", "decaissement", "décaissement", "sortie", "out", "outgoing"].includes(normalized)) {
    return "debit";
  }

  throw new ApiError(
    400,
    mode === "cash" ? "Le sens caisse est invalide." : "Le sens bancaire est invalide.",
  );
};

const getSignedMovementAmount = (direction, amount) =>
  direction === "credit" ? amount : -amount;

const roundAmount = (value) => Number(toNumber(value).toFixed(2));

const formatSignedBalance = (value) => formatDzdAmount(roundAmount(value));

const getBalanceTone = (value) => {
  if (toNumber(value) > 0) {
    return "positive";
  }

  if (toNumber(value) < 0) {
    return "negative";
  }

  return "neutral";
};

const readSpreadsheet = (file) => {
  if (!file?.buffer?.length) {
    throw new ApiError(400, "Le fichier d'import est requis.");
  }

  const fileName = cleanString(file.originalname).toLowerCase();

  if (fileName.endsWith(".csv")) {
    return readCsvSpreadsheet(file);
  }

  const workbook = XLSX.read(file.buffer, {
    type: "buffer",
    raw: false,
  });
  const firstSheetName = workbook.SheetNames[0];

  if (!firstSheetName) {
    throw new ApiError(400, "Le fichier importé est vide.");
  }

  const sheet = workbook.Sheets[firstSheetName];
  const matrix = XLSX.utils.sheet_to_json(sheet, {
    header: 1,
    defval: "",
    blankrows: false,
    raw: false,
  });

  if (!matrix.length) {
    throw new ApiError(400, "Le fichier importé est vide.");
  }

  const headers = matrix[0].map((column, index) => cleanString(column) || `column_${index + 1}`);
  const rows = matrix.slice(1).map((rowValues, rowIndex) =>
    headers.reduce(
      (accumulator, columnName, columnIndex) => {
        accumulator[columnName] = rowValues[columnIndex] ?? "";
        accumulator.__row_number = rowIndex + 2;
        return accumulator;
      },
      { __row_number: rowIndex + 2 },
    ),
  );

  return {
    headers,
    rows,
  };
};

const resolveImportField = (row, aliasesMap, fieldName) => {
  const aliases = aliasesMap[fieldName] || [];

  for (const [columnName, value] of Object.entries(row)) {
    if (aliases.includes(normalizeKey(columnName))) {
      return value;
    }
  }

  return "";
};

const resolveStatementMapping = (headers) => {
  const normalizedHeaders = new Map(headers.map((header) => [normalizeKey(header), header]));
  const mapping = {};

  Object.entries(BANK_STATEMENT_FIELD_ALIASES).forEach(([fieldName, aliases]) => {
    const matchedAlias = aliases.find((alias) => normalizedHeaders.has(alias));

    if (matchedAlias) {
      mapping[fieldName] = normalizedHeaders.get(matchedAlias);
    }
  });

  BANK_STATEMENT_REQUIRED_FIELDS.forEach((fieldName) => {
    if (!mapping[fieldName]) {
      throw new ApiError(
        400,
        `La colonne "${fieldName}" est obligatoire pour importer un relevé bancaire.`,
      );
    }
  });

  return mapping;
};

const fetchEntryById = async (db, companyId, entryId) => {
  if (!entryId) {
    return null;
  }

  const { rows } = await db.query(
    `
      SELECT id, entry_number, piece_ref, label, status
      FROM journal_entries
      WHERE id = $1
        AND company_id = $2
      LIMIT 1
    `,
    [entryId, companyId],
  );

  return rows[0] || null;
};

const fetchTreasuryAccountById = async (db, companyId, accountId) => {
  if (!accountId) {
    return null;
  }

  const { rows } = await db.query(
    `
      SELECT id, code, label, is_active, balance_normal_side
      FROM accounts
      WHERE id = $1
        AND company_id = $2
      LIMIT 1
    `,
    [accountId, companyId],
  );

  return rows[0] || null;
};

const fetchTreasuryAccountByCode = async (db, companyId, accountCode) => {
  const code = cleanString(accountCode);

  if (!code) {
    return null;
  }

  const { rows } = await db.query(
    `
      SELECT id, code, label, is_active, balance_normal_side
      FROM accounts
      WHERE company_id = $1
        AND code = $2
      LIMIT 1
    `,
    [companyId, code],
  );

  return rows[0] || null;
};

const ensureNoMovementBalanceOverride = async (db, tableName, foreignKeyName, resourceId) => {
  const { rows } = await db.query(
    `
      SELECT COUNT(*)::int AS movements_count
      FROM ${tableName}
      WHERE ${foreignKeyName} = $1
    `,
    [resourceId],
  );

  return Number(rows[0]?.movements_count || 0);
};

const fetchCashRegisterById = async (db, companyId, registerId) => {
  const { rows } = await db.query(
    `
      SELECT
        cr.id,
        cr.company_id,
        cr.name,
        cr.currency,
        cr.current_balance,
        cr.is_active,
        COALESCE(stats.movements_count, 0)::int AS movements_count,
        TO_CHAR(stats.last_movement_date, 'YYYY-MM-DD') AS last_movement_date
      FROM cash_registers cr
      LEFT JOIN LATERAL (
        SELECT
          COUNT(*) AS movements_count,
          MAX(cm.movement_date) AS last_movement_date
        FROM cash_movements cm
        WHERE cm.cash_register_id = cr.id
      ) AS stats
        ON TRUE
      WHERE cr.company_id = $1
        AND cr.id = $2
      LIMIT 1
    `,
    [companyId, registerId],
  );

  return rows[0] || null;
};

const fetchCashRegisterByName = async (db, companyId, name) => {
  const normalizedName = cleanString(name);

  if (!normalizedName) {
    return null;
  }

  const { rows } = await db.query(
    `
      SELECT id
      FROM cash_registers
      WHERE company_id = $1
        AND LOWER(name) = LOWER($2)
      LIMIT 1
    `,
    [companyId, normalizedName],
  );

  return rows[0] ? fetchCashRegisterById(db, companyId, rows[0].id) : null;
};

const fetchBankAccountById = async (db, companyId, bankAccountId) => {
  const { rows } = await db.query(
    `
      SELECT
        ba.id,
        ba.company_id,
        ba.account_id,
        ba.bank_name,
        ba.account_number,
        ba.rib,
        ba.currency,
        ba.current_balance,
        ba.is_active,
        a.code AS accounting_code,
        a.label AS accounting_label,
        a.balance_normal_side,
        COALESCE(movement_stats.movements_count, 0)::int AS movements_count,
        TO_CHAR(movement_stats.last_movement_date, 'YYYY-MM-DD') AS last_movement_date,
        TO_CHAR(rec_stats.reconciliation_date, 'YYYY-MM-DD') AS last_reconciliation_date,
        rec_stats.status AS last_reconciliation_status
      FROM bank_accounts ba
      LEFT JOIN accounts a
        ON a.id = ba.account_id
      LEFT JOIN LATERAL (
        SELECT
          COUNT(*) AS movements_count,
          MAX(bm.movement_date) AS last_movement_date
        FROM bank_movements bm
        WHERE bm.bank_account_id = ba.id
      ) AS movement_stats
        ON TRUE
      LEFT JOIN LATERAL (
        SELECT reconciliation_date, status
        FROM reconciliations r
        WHERE r.bank_account_id = ba.id
        ORDER BY reconciliation_date DESC, id DESC
        LIMIT 1
      ) AS rec_stats
        ON TRUE
      WHERE ba.company_id = $1
        AND ba.id = $2
      LIMIT 1
    `,
    [companyId, bankAccountId],
  );

  return rows[0] || null;
};

const fetchBankAccountByNumber = async (db, companyId, accountNumber) => {
  const normalizedNumber = cleanString(accountNumber);

  if (!normalizedNumber) {
    return null;
  }

  const { rows } = await db.query(
    `
      SELECT id
      FROM bank_accounts
      WHERE company_id = $1
        AND account_number = $2
      LIMIT 1
    `,
    [companyId, normalizedNumber],
  );

  return rows[0] ? fetchBankAccountById(db, companyId, rows[0].id) : null;
};

const mapCashRegisterRow = (row) => {
  const balance = roundAmount(row.current_balance);

  return {
    id: Number(row.id),
    company_id: Number(row.company_id),
    name: row.name,
    currency: row.currency,
    current_balance: balance,
    formatted_current_balance: formatSignedBalance(balance),
    balance_tone: getBalanceTone(balance),
    is_active: row.is_active,
    status_label: row.is_active ? "Active" : "Inactive",
    movements_count: Number(row.movements_count || 0),
    last_movement_date: row.last_movement_date || null,
  };
};

const mapBankAccountRow = (row) => {
  const balance = roundAmount(row.current_balance);

  return {
    id: Number(row.id),
    company_id: Number(row.company_id),
    account_id: row.account_id ? Number(row.account_id) : null,
    bank_name: row.bank_name,
    account_number: row.account_number,
    rib: row.rib,
    currency: row.currency,
    current_balance: balance,
    formatted_current_balance: formatSignedBalance(balance),
    balance_tone: getBalanceTone(balance),
    is_active: row.is_active,
    status_label: row.is_active ? "Actif" : "Inactif",
    accounting_code: row.accounting_code || null,
    accounting_label: row.accounting_label || null,
    movements_count: Number(row.movements_count || 0),
    last_movement_date: row.last_movement_date || null,
    last_reconciliation_date: row.last_reconciliation_date || null,
    last_reconciliation_status: row.last_reconciliation_status || null,
    last_reconciliation_status_label:
      RECONCILIATION_STATUS_LABELS[row.last_reconciliation_status] || null,
  };
};

const fetchCashOpeningContext = async (db, registerId, currentBalance, fromDate = null) => {
  const { rows } = await db.query(
    `
      SELECT
        COALESCE(
          SUM(
            CASE
              WHEN direction = 'credit' THEN amount
              ELSE -amount
            END
          ),
          0
        )::numeric AS total_delta,
        COALESCE(
          SUM(
            CASE
              WHEN $2::date IS NOT NULL
                AND movement_date < $2::date
                THEN CASE
                  WHEN direction = 'credit' THEN amount
                  ELSE -amount
                END
              ELSE 0
            END
          ),
          0
        )::numeric AS before_from_delta
      FROM cash_movements
      WHERE cash_register_id = $1
    `,
    [registerId, fromDate],
  );

  const totalDelta = roundAmount(rows[0]?.total_delta);
  const beforeFromDelta = roundAmount(rows[0]?.before_from_delta);
  const openingBalance = roundAmount(currentBalance - totalDelta);
  const startBalance = roundAmount(openingBalance + beforeFromDelta);

  return {
    opening_balance: openingBalance,
    start_balance: startBalance,
  };
};

const fetchBankOpeningContext = async (db, bankAccountId, currentBalance, fromDate = null) => {
  const { rows } = await db.query(
    `
      SELECT
        COALESCE(
          SUM(
            CASE
              WHEN direction = 'credit' THEN amount
              ELSE -amount
            END
          ),
          0
        )::numeric AS total_delta,
        COALESCE(
          SUM(
            CASE
              WHEN $2::date IS NOT NULL
                AND movement_date < $2::date
                THEN CASE
                  WHEN direction = 'credit' THEN amount
                  ELSE -amount
                END
              ELSE 0
            END
          ),
          0
        )::numeric AS before_from_delta
      FROM bank_movements
      WHERE bank_account_id = $1
    `,
    [bankAccountId, fromDate],
  );

  const totalDelta = roundAmount(rows[0]?.total_delta);
  const beforeFromDelta = roundAmount(rows[0]?.before_from_delta);
  const openingBalance = roundAmount(currentBalance - totalDelta);
  const startBalance = roundAmount(openingBalance + beforeFromDelta);

  return {
    opening_balance: openingBalance,
    start_balance: startBalance,
  };
};

const parseCashRegisterPayload = async ({ db, companyId, body, existingItem = null }) => {
  const name = cleanString(body.name || existingItem?.name);
  const currency = cleanString(body.currency || existingItem?.currency || "DZD").toUpperCase();
  const isActive =
    parseBoolean(body.is_active, existingItem?.is_active ?? true) ??
    existingItem?.is_active ??
    true;
  const currentBalanceRaw =
    body.current_balance ?? body.opening_balance ?? existingItem?.current_balance ?? 0;
  const currentBalance = parseAmount(currentBalanceRaw, "Solde initial");

  if (!name) {
    throw new ApiError(400, "Le nom de la caisse est obligatoire.");
  }

  if (!currency) {
    throw new ApiError(400, "La devise est obligatoire.");
  }

  if (existingItem && Number(existingItem.movements_count || 0) > 0) {
    const existingBalance = roundAmount(existingItem.current_balance);

    if (roundAmount(currentBalance) !== existingBalance) {
      throw new ApiError(
        409,
        "Le solde d'une caisse avec mouvements ne peut plus être modifié manuellement.",
      );
    }
  }

  return {
    name,
    currency,
    current_balance: currentBalance,
    is_active: isActive,
  };
};

const parseBankAccountPayload = async ({ db, companyId, body, existingItem = null }) => {
  const bankName = cleanString(body.bank_name || existingItem?.bank_name);
  const accountNumber = cleanString(body.account_number || existingItem?.account_number);
  const rib = cleanString(body.rib || existingItem?.rib) || null;
  const currency = cleanString(body.currency || existingItem?.currency || "DZD").toUpperCase();
  const accountId = Number(body.account_id ?? existingItem?.account_id ?? 0) || null;
  const isActive =
    parseBoolean(body.is_active, existingItem?.is_active ?? true) ??
    existingItem?.is_active ??
    true;
  const currentBalanceRaw =
    body.current_balance ?? body.opening_balance ?? existingItem?.current_balance ?? 0;
  const currentBalance = parseAmount(currentBalanceRaw, "Solde initial");

  if (!bankName) {
    throw new ApiError(400, "Le nom de la banque est obligatoire.");
  }

  if (!accountNumber) {
    throw new ApiError(400, "Le numéro de compte bancaire est obligatoire.");
  }

  if (!currency) {
    throw new ApiError(400, "La devise est obligatoire.");
  }

  let account = null;

  if (accountId) {
    account = await fetchTreasuryAccountById(db, companyId, accountId);

    if (!account) {
      throw new ApiError(400, "Le compte comptable lié est introuvable.");
    }

    if (!account.is_active) {
      throw new ApiError(400, "Le compte comptable lié est inactif.");
    }
  }

  if (existingItem && Number(existingItem.movements_count || 0) > 0) {
    const existingBalance = roundAmount(existingItem.current_balance);

    if (roundAmount(currentBalance) !== existingBalance) {
      throw new ApiError(
        409,
        "Le solde d'un compte bancaire avec mouvements ne peut plus être modifié manuellement.",
      );
    }
  }

  return {
    bank_name: bankName,
    account_number: accountNumber,
    rib,
    currency,
    account_id: account ? Number(account.id) : null,
    current_balance: currentBalance,
    is_active: isActive,
  };
};

const saveCashRegister = async ({
  db,
  companyId,
  body,
  existingItem = null,
  userId = null,
  ipAddress = null,
}) => {
  const payload = await parseCashRegisterPayload({
    db,
    companyId,
    body,
    existingItem,
  });
  let registerId = existingItem?.id || null;

  if (existingItem) {
    await db.query(
      `
        UPDATE cash_registers
        SET
          name = $2,
          currency = $3,
          current_balance = $4,
          is_active = $5
        WHERE id = $1
          AND company_id = $6
      `,
      [
        existingItem.id,
        payload.name,
        payload.currency,
        payload.current_balance,
        payload.is_active,
        companyId,
      ],
    );
  } else {
    const { rows } = await db.query(
      `
        INSERT INTO cash_registers (
          company_id,
          name,
          currency,
          current_balance,
          is_active
        )
        VALUES ($1, $2, $3, $4, $5)
        RETURNING id
      `,
      [
        companyId,
        payload.name,
        payload.currency,
        payload.current_balance,
        payload.is_active,
      ],
    );

    registerId = Number(rows[0].id);
  }

  registerId = registerId || Number(existingItem.id);

  const item = await fetchCashRegisterById(db, companyId, registerId);

  await logAudit(db, {
    companyId,
    userId,
    action: existingItem ? "cash_registers.update" : "cash_registers.create",
    entityType: "cash_register",
    entityId: registerId,
    oldData: existingItem ? sanitizeCashRegisterForAudit(mapCashRegisterRow(existingItem)) : null,
    newData: sanitizeCashRegisterForAudit(mapCashRegisterRow(item)),
    ipAddress,
  });

  return mapCashRegisterRow(item);
};

const saveBankAccount = async ({
  db,
  companyId,
  body,
  existingItem = null,
  userId = null,
  ipAddress = null,
}) => {
  const payload = await parseBankAccountPayload({
    db,
    companyId,
    body,
    existingItem,
  });
  let bankAccountId = existingItem?.id || null;

  if (existingItem) {
    await db.query(
      `
        UPDATE bank_accounts
        SET
          account_id = $2,
          bank_name = $3,
          account_number = $4,
          rib = $5,
          currency = $6,
          current_balance = $7,
          is_active = $8
        WHERE id = $1
          AND company_id = $9
      `,
      [
        existingItem.id,
        payload.account_id,
        payload.bank_name,
        payload.account_number,
        payload.rib,
        payload.currency,
        payload.current_balance,
        payload.is_active,
        companyId,
      ],
    );
  } else {
    const { rows } = await db.query(
      `
        INSERT INTO bank_accounts (
          company_id,
          account_id,
          bank_name,
          account_number,
          rib,
          currency,
          current_balance,
          is_active
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
        RETURNING id
      `,
      [
        companyId,
        payload.account_id,
        payload.bank_name,
        payload.account_number,
        payload.rib,
        payload.currency,
        payload.current_balance,
        payload.is_active,
      ],
    );

    bankAccountId = Number(rows[0].id);
  }

  bankAccountId = bankAccountId || Number(existingItem.id);

  const item = await fetchBankAccountById(db, companyId, bankAccountId);

  await logAudit(db, {
    companyId,
    userId,
    action: existingItem ? "bank_accounts.update" : "bank_accounts.create",
    entityType: "bank_account",
    entityId: bankAccountId,
    oldData: existingItem ? sanitizeBankAccountForAudit(mapBankAccountRow(existingItem)) : null,
    newData: sanitizeBankAccountForAudit(mapBankAccountRow(item)),
    ipAddress,
  });

  return mapBankAccountRow(item);
};

const buildCashRegisterTemplateBuffer = () => {
  const worksheet = XLSX.utils.json_to_sheet(
    [
      {
        Nom: "Caisse principale",
        Devise: "DZD",
        "Solde initial": 0,
        Actif: "oui",
      },
    ],
    {
      header: CASH_REGISTER_TEMPLATE_HEADERS,
    },
  );
  const workbook = XLSX.utils.book_new();

  XLSX.utils.book_append_sheet(workbook, worksheet, "Caisses");

  return XLSX.write(workbook, {
    type: "buffer",
    bookType: "xlsx",
  });
};

const buildBankAccountTemplateBuffer = () => {
  const worksheet = XLSX.utils.json_to_sheet(
    [
      {
        Banque: "CPA",
        "N° compte": "002001234567890",
        RIB: "007 999 000000123456 12",
        Devise: "DZD",
        "Solde initial": 0,
        "Compte comptable": "512000",
        Actif: "oui",
      },
    ],
    {
      header: BANK_ACCOUNT_TEMPLATE_HEADERS,
    },
  );
  const workbook = XLSX.utils.book_new();

  XLSX.utils.book_append_sheet(workbook, worksheet, "Banques");

  return XLSX.write(workbook, {
    type: "buffer",
    bookType: "xlsx",
  });
};

const getImportMode = (value) => {
  const normalized = cleanString(value).toLowerCase();

  if (["preview", "commit"].includes(normalized)) {
    return normalized;
  }

  return "preview";
};

const buildCashRegisterImportPayload = async ({ db, companyId, row, existingItem = null }) =>
  parseCashRegisterPayload({
    db,
    companyId,
    body: {
      name: resolveImportField(row, CASH_REGISTER_IMPORT_ALIASES, "name"),
      currency: resolveImportField(row, CASH_REGISTER_IMPORT_ALIASES, "currency"),
      current_balance: resolveImportField(
        row,
        CASH_REGISTER_IMPORT_ALIASES,
        "current_balance",
      ),
      is_active: resolveImportField(row, CASH_REGISTER_IMPORT_ALIASES, "is_active"),
    },
    existingItem,
  });

const buildBankAccountImportPayload = async ({ db, companyId, row, existingItem = null }) => {
  const accountCode = resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "account_code");
  const account = accountCode
    ? await fetchTreasuryAccountByCode(db, companyId, accountCode)
    : null;

  if (accountCode && !account) {
    throw new ApiError(400, "Le compte comptable lié est introuvable.");
  }

  const payload = await parseBankAccountPayload({
    db,
    companyId,
    body: {
      bank_name: resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "bank_name"),
      account_number: resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "account_number"),
      rib: resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "rib"),
      currency: resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "currency"),
      current_balance: resolveImportField(
        row,
        BANK_ACCOUNT_IMPORT_ALIASES,
        "current_balance",
      ),
      account_id: account ? Number(account.id) : null,
      is_active: resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "is_active"),
    },
    existingItem,
  });

  return {
    ...payload,
    linked_account_code: account?.code || existingItem?.accounting_code || null,
  };
};

const buildCashRegisterImportPreview = async ({ db, companyId, rows }) => {
  const previewRows = [];
  const seenNames = new Set();

  for (const [index, row] of rows.entries()) {
    const rowNumber = Number(row.__row_number || index + 2);
    const rawName = cleanString(resolveImportField(row, CASH_REGISTER_IMPORT_ALIASES, "name"));
    const errors = [];
    let existingItem = null;
    let payload = null;

    if (!rawName) {
      errors.push("Le nom de la caisse est obligatoire.");
    }

    if (rawName) {
      const duplicateKey = rawName.toLowerCase();

      if (seenNames.has(duplicateKey)) {
        errors.push("Nom de caisse en doublon dans le fichier.");
      } else {
        seenNames.add(duplicateKey);
      }
    }

    try {
      existingItem = rawName ? await fetchCashRegisterByName(db, companyId, rawName) : null;
      payload = await buildCashRegisterImportPayload({
        db,
        companyId,
        row,
        existingItem,
      });
    } catch (error) {
      errors.push(error.message || "Ligne d'import invalide.");
    }

    previewRows.push({
      row_number: rowNumber,
      original: row,
      normalized: payload
        ? {
            name: payload.name,
            currency: payload.currency,
            current_balance: payload.current_balance,
            is_active: payload.is_active,
            action: existingItem ? "update" : "create",
          }
        : {
            name: rawName || null,
            action: existingItem ? "update" : "create",
          },
      errors,
      warnings: [],
    });
  }

  return {
    summary: {
      total_rows: rows.length,
      valid_rows: previewRows.filter((row) => row.errors.length === 0).length,
      error_rows: previewRows.filter((row) => row.errors.length > 0).length,
      warning_rows: 0,
    },
    rows: previewRows,
  };
};

const buildBankAccountImportPreview = async ({ db, companyId, rows }) => {
  const previewRows = [];
  const seenNumbers = new Set();

  for (const [index, row] of rows.entries()) {
    const rowNumber = Number(row.__row_number || index + 2);
    const rawAccountNumber = cleanString(
      resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "account_number"),
    );
    const rawBankName = cleanString(resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "bank_name"));
    const rawAccountCode = cleanString(resolveImportField(row, BANK_ACCOUNT_IMPORT_ALIASES, "account_code"));
    const errors = [];
    let existingItem = null;
    let payload = null;

    if (!rawBankName) {
      errors.push("Le nom de la banque est obligatoire.");
    }

    if (!rawAccountNumber) {
      errors.push("Le numéro de compte bancaire est obligatoire.");
    }

    if (rawAccountNumber) {
      if (seenNumbers.has(rawAccountNumber)) {
        errors.push("Numéro de compte bancaire en doublon dans le fichier.");
      } else {
        seenNumbers.add(rawAccountNumber);
      }
    }

    try {
      existingItem = rawAccountNumber
        ? await fetchBankAccountByNumber(db, companyId, rawAccountNumber)
        : null;
      payload = await buildBankAccountImportPayload({
        db,
        companyId,
        row,
        existingItem,
      });
    } catch (error) {
      errors.push(error.message || "Ligne d'import invalide.");
    }

    previewRows.push({
      row_number: rowNumber,
      original: row,
      normalized: payload
        ? {
            bank_name: payload.bank_name,
            account_number: payload.account_number,
            rib: payload.rib,
            currency: payload.currency,
            current_balance: payload.current_balance,
            account_id: payload.account_id,
            account_code: payload.linked_account_code,
            is_active: payload.is_active,
            action: existingItem ? "update" : "create",
          }
        : {
            bank_name: rawBankName || null,
            account_number: rawAccountNumber || null,
            account_code: rawAccountCode || null,
            action: existingItem ? "update" : "create",
          },
      errors,
      warnings: [],
    });
  }

  return {
    summary: {
      total_rows: rows.length,
      valid_rows: previewRows.filter((row) => row.errors.length === 0).length,
      error_rows: previewRows.filter((row) => row.errors.length > 0).length,
      warning_rows: 0,
    },
    rows: previewRows,
  };
};

const commitCashRegisterImport = async ({ db, companyId, rows, userId, ipAddress }) => {
  let createdCount = 0;
  let updatedCount = 0;
  const logs = {
    successes: [],
    errors: [],
  };

  for (const row of rows) {
    if (row.errors.length > 0) {
      logs.errors.push({
        row_number: row.row_number,
        name: row.normalized?.name || null,
        messages: row.errors,
      });
      continue;
    }

    const existingItem = row.normalized?.name
      ? await fetchCashRegisterByName(db, companyId, row.normalized.name)
      : null;
    const item = await saveCashRegister({
      db,
      companyId,
      body: {
        name: resolveImportField(row.original, CASH_REGISTER_IMPORT_ALIASES, "name"),
        currency: resolveImportField(row.original, CASH_REGISTER_IMPORT_ALIASES, "currency"),
        current_balance: resolveImportField(
          row.original,
          CASH_REGISTER_IMPORT_ALIASES,
          "current_balance",
        ),
        is_active: resolveImportField(row.original, CASH_REGISTER_IMPORT_ALIASES, "is_active"),
      },
      existingItem,
      userId,
      ipAddress,
    });

    if (existingItem) {
      updatedCount += 1;
    } else {
      createdCount += 1;
    }

    logs.successes.push({
      row_number: row.row_number,
      name: item.name,
      action: existingItem ? "updated" : "created",
    });
  }

  await logAudit(db, {
    companyId,
    userId,
    action: "cash_registers.import",
    entityType: "cash_register_import",
    newData: {
      created_count: createdCount,
      updated_count: updatedCount,
      error_count: logs.errors.length,
      total_rows: rows.length,
    },
    ipAddress,
  });

  return {
    created_count: createdCount,
    updated_count: updatedCount,
    error_count: logs.errors.length,
    warning_count: 0,
    logs,
  };
};

const commitBankAccountImport = async ({ db, companyId, rows, userId, ipAddress }) => {
  let createdCount = 0;
  let updatedCount = 0;
  const logs = {
    successes: [],
    errors: [],
  };

  for (const row of rows) {
    if (row.errors.length > 0) {
      logs.errors.push({
        row_number: row.row_number,
        account_number: row.normalized?.account_number || null,
        messages: row.errors,
      });
      continue;
    }

    const existingItem = row.normalized?.account_number
      ? await fetchBankAccountByNumber(db, companyId, row.normalized.account_number)
      : null;
    const item = await saveBankAccount({
      db,
      companyId,
      body: {
        bank_name: resolveImportField(row.original, BANK_ACCOUNT_IMPORT_ALIASES, "bank_name"),
        account_number: resolveImportField(
          row.original,
          BANK_ACCOUNT_IMPORT_ALIASES,
          "account_number",
        ),
        rib: resolveImportField(row.original, BANK_ACCOUNT_IMPORT_ALIASES, "rib"),
        currency: resolveImportField(row.original, BANK_ACCOUNT_IMPORT_ALIASES, "currency"),
        current_balance: resolveImportField(
          row.original,
          BANK_ACCOUNT_IMPORT_ALIASES,
          "current_balance",
        ),
        account_id: row.normalized?.account_id || null,
        is_active: resolveImportField(row.original, BANK_ACCOUNT_IMPORT_ALIASES, "is_active"),
      },
      existingItem,
      userId,
      ipAddress,
    });

    if (existingItem) {
      updatedCount += 1;
    } else {
      createdCount += 1;
    }

    logs.successes.push({
      row_number: row.row_number,
      account_number: item.account_number,
      action: existingItem ? "updated" : "created",
    });
  }

  await logAudit(db, {
    companyId,
    userId,
    action: "bank_accounts.import",
    entityType: "bank_account_import",
    newData: {
      created_count: createdCount,
      updated_count: updatedCount,
      error_count: logs.errors.length,
      total_rows: rows.length,
    },
    ipAddress,
  });

  return {
    created_count: createdCount,
    updated_count: updatedCount,
    error_count: logs.errors.length,
    warning_count: 0,
    logs,
  };
};

const sanitizeCashRegisterForAudit = (item) => ({
  id: item.id,
  name: item.name,
  currency: item.currency,
  current_balance: item.current_balance,
  is_active: item.is_active,
});

const sanitizeBankAccountForAudit = (item) => ({
  id: item.id,
  bank_name: item.bank_name,
  account_number: item.account_number,
  rib: item.rib,
  currency: item.currency,
  current_balance: item.current_balance,
  account_id: item.account_id,
  is_active: item.is_active,
});

const sanitizeMovementForAudit = (movement) => ({
  id: movement.id,
  movement_date: movement.movement_date,
  label: movement.label,
  amount: movement.amount,
  direction: movement.direction,
  piece_ref: movement.piece_ref || movement.operation_number || null,
  entry_id: movement.entry_id || null,
});

const fetchCashMovementById = async (db, movementId) => {
  const { rows } = await db.query(
    `
      SELECT id, movement_date, label, amount, direction, piece_ref, entry_id
      FROM cash_movements
      WHERE id = $1
      LIMIT 1
    `,
    [movementId],
  );

  return rows[0] || null;
};

const fetchBankMovementById = async (db, movementId) => {
  const { rows } = await db.query(
    `
      SELECT id, movement_date, label, amount, direction, operation_number, entry_id
      FROM bank_movements
      WHERE id = $1
      LIMIT 1
    `,
    [movementId],
  );

  return rows[0] || null;
};

const buildCashMovementItems = ({ rows, startBalance }) => {
  let runningBalance = roundAmount(startBalance);

  return rows.map((row) => {
    const amount = roundAmount(row.amount);
    const signedAmount = roundAmount(getSignedMovementAmount(row.direction, amount));
    runningBalance = roundAmount(runningBalance + signedAmount);

    return {
      id: Number(row.id),
      movement_date: row.movement_date,
      piece_ref: row.piece_ref,
      label: row.label,
      direction: row.direction,
      direction_label: CASH_DIRECTION_LABELS[row.direction] || row.direction,
      amount,
      signed_amount: signedAmount,
      formatted_amount: formatDzdAmount(amount),
      formatted_entry: row.direction === "credit" ? formatDzdAmount(amount) : "—",
      formatted_exit: row.direction === "debit" ? formatDzdAmount(amount) : "—",
      running_balance: runningBalance,
      formatted_running_balance: formatSignedBalance(runningBalance),
      running_balance_tone: getBalanceTone(runningBalance),
      entry_id: row.entry_id ? Number(row.entry_id) : null,
      entry_number: row.entry_number,
      linked_entry_label: row.entry_id
        ? `${row.entry_number || row.linked_piece_ref || `#${row.entry_id}`}`
        : null,
    };
  });
};

const buildBankMovementItems = ({ rows, startBalance }) => {
  let runningBalance = roundAmount(startBalance);

  return rows.map((row) => {
    const amount = roundAmount(row.amount);
    const signedAmount = roundAmount(getSignedMovementAmount(row.direction, amount));
    runningBalance = roundAmount(runningBalance + signedAmount);

    return {
      id: Number(row.id),
      movement_date: row.movement_date,
      operation_number: row.operation_number,
      label: row.label,
      direction: row.direction,
      direction_label: BANK_DIRECTION_LABELS[row.direction] || row.direction,
      amount,
      signed_amount: signedAmount,
      formatted_amount: formatDzdAmount(amount),
      formatted_debit: row.direction === "debit" ? formatDzdAmount(amount) : "—",
      formatted_credit: row.direction === "credit" ? formatDzdAmount(amount) : "—",
      running_balance: runningBalance,
      formatted_running_balance: formatSignedBalance(runningBalance),
      running_balance_tone: getBalanceTone(runningBalance),
      is_reconciled: row.is_reconciled,
      reconciliation_id: row.reconciliation_id ? Number(row.reconciliation_id) : null,
      entry_id: row.entry_id ? Number(row.entry_id) : null,
      entry_number: row.entry_number,
      piece_ref: row.linked_piece_ref,
      linked_entry_label: row.entry_id
        ? `${row.entry_number || row.linked_piece_ref || `#${row.entry_id}`}`
        : null,
    };
  });
};

const fetchOpenReconciliationByBankAccount = async (db, companyId, bankAccountId) => {
  const { rows } = await db.query(
    `
      SELECT id
      FROM reconciliations
      WHERE company_id = $1
        AND bank_account_id = $2
        AND status = 'open'
      ORDER BY reconciliation_date DESC, id DESC
      LIMIT 1
    `,
    [companyId, bankAccountId],
  );

  return rows[0] ? Number(rows[0].id) : null;
};

const fetchReconciliationById = async (db, companyId, reconciliationId) => {
  const { rows } = await db.query(
    `
      SELECT
        r.id,
        r.company_id,
        r.bank_account_id,
        TO_CHAR(r.reconciliation_date, 'YYYY-MM-DD') AS reconciliation_date,
        r.bank_balance,
        r.book_balance,
        r.difference,
        r.status,
        ba.bank_name,
        ba.account_number,
        ba.rib,
        ba.currency,
        ba.current_balance,
        ba.account_id,
        a.code AS accounting_code,
        a.label AS accounting_label,
        a.balance_normal_side
      FROM reconciliations r
      JOIN bank_accounts ba
        ON ba.id = r.bank_account_id
      LEFT JOIN accounts a
        ON a.id = ba.account_id
      WHERE r.company_id = $1
        AND r.id = $2
      LIMIT 1
    `,
    [companyId, reconciliationId],
  );

  return rows[0] || null;
};

const mapReconciliationHeader = (row) => ({
  id: Number(row.id),
  company_id: Number(row.company_id),
  bank_account_id: Number(row.bank_account_id),
  reconciliation_date: row.reconciliation_date,
  bank_balance: roundAmount(row.bank_balance),
  formatted_bank_balance: formatSignedBalance(row.bank_balance),
  book_balance: roundAmount(row.book_balance),
  formatted_book_balance: formatSignedBalance(row.book_balance),
  difference: roundAmount(row.difference),
  formatted_difference: formatSignedBalance(row.difference),
  difference_tone: getBalanceTone(row.difference),
  status: row.status,
  status_label: RECONCILIATION_STATUS_LABELS[row.status] || row.status,
  bank_account: {
    id: Number(row.bank_account_id),
    bank_name: row.bank_name,
    account_number: row.account_number,
    rib: row.rib,
    currency: row.currency,
    current_balance: roundAmount(row.current_balance),
    formatted_current_balance: formatSignedBalance(row.current_balance),
    account_id: row.account_id ? Number(row.account_id) : null,
    accounting_code: row.accounting_code || null,
    accounting_label: row.accounting_label || null,
    balance_normal_side: row.balance_normal_side || "debit",
  },
});

const getLedgerEffectExpression = (balanceNormalSide = "debit") =>
  balanceNormalSide === "credit" ? "el.credit - el.debit" : "el.debit - el.credit";

const fetchBookBalanceForReconciliation = async (db, companyId, reconciliation) => {
  if (!reconciliation.bank_account.account_id) {
    return 0;
  }

  const effectExpression = getLedgerEffectExpression(
    reconciliation.bank_account.balance_normal_side,
  );
  const { rows } = await db.query(
    `
      SELECT
        COALESCE(SUM(${effectExpression}), 0)::numeric AS book_balance
      FROM entry_lines el
      JOIN journal_entries je
        ON je.id = el.entry_id
      WHERE je.company_id = $1
        AND je.status = 'validated'
        AND el.account_id = $2
        AND je.entry_date <= $3::date
    `,
    [
      companyId,
      reconciliation.bank_account.account_id,
      reconciliation.reconciliation_date,
    ],
  );

  return roundAmount(rows[0]?.book_balance);
};

const fetchReconciliationBankMovements = async (db, companyId, reconciliation, { closedView = false }) => {
  const movementScopeCondition = closedView
    ? "bm.reconciliation_id = $4"
    : "(bm.reconciliation_id IS NULL OR bm.reconciliation_id = $4)";

  const { rows } = await db.query(
    `
      SELECT
        bm.id,
        TO_CHAR(bm.movement_date, 'YYYY-MM-DD') AS movement_date,
        bm.operation_number,
        bm.label,
        bm.amount,
        bm.direction,
        bm.is_reconciled,
        bm.reconciliation_id,
        bm.entry_id,
        je.entry_number,
        je.piece_ref AS linked_piece_ref,
        CASE
          WHEN bm.direction = 'credit' THEN bm.amount
          ELSE -bm.amount
        END::numeric AS balance_effect
      FROM bank_movements bm
      LEFT JOIN journal_entries je
        ON je.id = bm.entry_id
      WHERE bm.company_id = $1
        AND bm.bank_account_id = $2
        AND bm.movement_date <= $3::date
        AND ${movementScopeCondition}
      ORDER BY bm.movement_date ASC, bm.id ASC
    `,
    [
      companyId,
      reconciliation.bank_account_id,
      reconciliation.reconciliation_date,
      reconciliation.id,
    ],
  );

  return rows.map((row) => ({
    id: Number(row.id),
    movement_date: row.movement_date,
    operation_number: row.operation_number,
    label: row.label,
    amount: roundAmount(row.amount),
    direction: row.direction,
    direction_label: BANK_DIRECTION_LABELS[row.direction] || row.direction,
    balance_effect: roundAmount(row.balance_effect),
    formatted_amount: formatDzdAmount(row.amount),
    formatted_effect: formatSignedBalance(row.balance_effect),
    selected: Number(row.reconciliation_id || 0) === Number(reconciliation.id),
    is_reconciled: row.is_reconciled,
    entry_id: row.entry_id ? Number(row.entry_id) : null,
    entry_number: row.entry_number,
    piece_ref: row.linked_piece_ref,
  }));
};

const fetchReconciliationLedgerLines = async (db, companyId, reconciliation, { closedView = false }) => {
  if (!reconciliation.bank_account.account_id) {
    return [];
  }

  const effectExpression = getLedgerEffectExpression(
    reconciliation.bank_account.balance_normal_side,
  );
  const lineScopeCondition = closedView
    ? "rel.reconciliation_id = $4"
    : "(rel.entry_line_id IS NULL OR rel.reconciliation_id = $4)";
  const { rows } = await db.query(
    `
      SELECT
        el.id,
        je.id AS entry_id,
        TO_CHAR(je.entry_date, 'YYYY-MM-DD') AS entry_date,
        je.entry_number,
        je.piece_ref,
        j.code AS journal_code,
        COALESCE(NULLIF(el.label, ''), je.label) AS label,
        el.debit,
        el.credit,
        ${effectExpression}::numeric AS balance_effect,
        rel.reconciliation_id
      FROM entry_lines el
      JOIN journal_entries je
        ON je.id = el.entry_id
      JOIN journals j
        ON j.id = je.journal_id
      LEFT JOIN reconciliation_entry_lines rel
        ON rel.entry_line_id = el.id
      WHERE je.company_id = $1
        AND je.status = 'validated'
        AND el.account_id = $2
        AND je.entry_date <= $3::date
        AND ${lineScopeCondition}
      ORDER BY je.entry_date ASC, je.id ASC, el.id ASC
    `,
    [
      companyId,
      reconciliation.bank_account.account_id,
      reconciliation.reconciliation_date,
      reconciliation.id,
    ],
  );

  return rows.map((row) => ({
    id: Number(row.id),
    entry_id: Number(row.entry_id),
    entry_date: row.entry_date,
    entry_number: row.entry_number,
    piece_ref: row.piece_ref,
    journal_code: row.journal_code,
    label: row.label,
    debit: roundAmount(row.debit),
    credit: roundAmount(row.credit),
    formatted_debit: formatDzdAmount(row.debit),
    formatted_credit: formatDzdAmount(row.credit),
    balance_effect: roundAmount(row.balance_effect),
    formatted_effect: formatSignedBalance(row.balance_effect),
    selected: Number(row.reconciliation_id || 0) === Number(reconciliation.id),
  }));
};

const buildOpenReconciliationSummary = ({ reconciliation, bankMovements, ledgerLines, bookBalance }) => {
  const selectedBankTotal = roundAmount(
    bankMovements
      .filter((movement) => movement.selected)
      .reduce((sum, movement) => sum + movement.balance_effect, 0),
  );
  const selectedBookTotal = roundAmount(
    ledgerLines
      .filter((line) => line.selected)
      .reduce((sum, line) => sum + line.balance_effect, 0),
  );
  const unmatchedBankAmount = roundAmount(
    bankMovements
      .filter((movement) => !movement.selected)
      .reduce((sum, movement) => sum + movement.balance_effect, 0),
  );
  const unmatchedBookAmount = roundAmount(
    ledgerLines
      .filter((line) => !line.selected)
      .reduce((sum, line) => sum + line.balance_effect, 0),
  );
  const difference = roundAmount(
    reconciliation.bank_balance -
      bookBalance -
      unmatchedBankAmount +
      unmatchedBookAmount,
  );

  return {
    bank_balance: reconciliation.bank_balance,
    formatted_bank_balance: formatSignedBalance(reconciliation.bank_balance),
    book_balance: bookBalance,
    formatted_book_balance: formatSignedBalance(bookBalance),
    matched_bank_amount: selectedBankTotal,
    formatted_matched_bank_amount: formatSignedBalance(selectedBankTotal),
    matched_book_amount: selectedBookTotal,
    formatted_matched_book_amount: formatSignedBalance(selectedBookTotal),
    unmatched_bank_amount: unmatchedBankAmount,
    formatted_unmatched_bank_amount: formatSignedBalance(unmatchedBankAmount),
    unmatched_book_amount: unmatchedBookAmount,
    formatted_unmatched_book_amount: formatSignedBalance(unmatchedBookAmount),
    difference,
    formatted_difference: formatSignedBalance(difference),
    difference_tone: getBalanceTone(difference),
    matched_bank_count: bankMovements.filter((movement) => movement.selected).length,
    matched_book_count: ledgerLines.filter((line) => line.selected).length,
  };
};

const buildClosedReconciliationSummary = ({ reconciliation, bankMovements, ledgerLines }) => ({
  bank_balance: reconciliation.bank_balance,
  formatted_bank_balance: formatSignedBalance(reconciliation.bank_balance),
  book_balance: reconciliation.book_balance,
  formatted_book_balance: formatSignedBalance(reconciliation.book_balance),
  matched_bank_amount: roundAmount(
    bankMovements.reduce((sum, movement) => sum + movement.balance_effect, 0),
  ),
  formatted_matched_bank_amount: formatSignedBalance(
    bankMovements.reduce((sum, movement) => sum + movement.balance_effect, 0),
  ),
  matched_book_amount: roundAmount(
    ledgerLines.reduce((sum, line) => sum + line.balance_effect, 0),
  ),
  formatted_matched_book_amount: formatSignedBalance(
    ledgerLines.reduce((sum, line) => sum + line.balance_effect, 0),
  ),
  unmatched_bank_amount: 0,
  formatted_unmatched_bank_amount: formatSignedBalance(0),
  unmatched_book_amount: 0,
  formatted_unmatched_book_amount: formatSignedBalance(0),
  difference: roundAmount(reconciliation.difference),
  formatted_difference: formatSignedBalance(reconciliation.difference),
  difference_tone: getBalanceTone(reconciliation.difference),
  matched_bank_count: bankMovements.length,
  matched_book_count: ledgerLines.length,
});

const fetchReconciliationDetail = async (db, companyId, reconciliationId) => {
  const baseRow = await fetchReconciliationById(db, companyId, reconciliationId);

  if (!baseRow) {
    throw new ApiError(404, "Rapprochement introuvable.");
  }

  const reconciliation = mapReconciliationHeader(baseRow);
  const closedView = reconciliation.status === "closed";
  const bankMovements = await fetchReconciliationBankMovements(
    db,
    companyId,
    reconciliation,
    { closedView },
  );
  const ledgerLines = await fetchReconciliationLedgerLines(
    db,
    companyId,
    reconciliation,
    { closedView },
  );
  const bookBalance = closedView
    ? roundAmount(reconciliation.book_balance)
    : await fetchBookBalanceForReconciliation(db, companyId, reconciliation);

  const summary = closedView
    ? buildClosedReconciliationSummary({ reconciliation, bankMovements, ledgerLines })
    : buildOpenReconciliationSummary({
        reconciliation,
        bankMovements,
        ledgerLines,
        bookBalance,
      });

  return {
    reconciliation: {
      ...reconciliation,
      book_balance: summary.book_balance,
      formatted_book_balance: summary.formatted_book_balance,
      difference: summary.difference,
      formatted_difference: summary.formatted_difference,
    },
    bank_movements: bankMovements,
    ledger_lines: ledgerLines,
    matched_bank_movement_ids: bankMovements.filter((movement) => movement.selected).map((movement) => movement.id),
    matched_entry_line_ids: ledgerLines.filter((line) => line.selected).map((line) => line.id),
    summary,
    permissions: {
      can_save: reconciliation.status === "open",
      can_close: reconciliation.status === "open" && summary.difference === 0,
    },
  };
};

const saveReconciliationState = async (db, companyId, reconciliationId, bankMovementIds, entryLineIds) => {
  const detail = await fetchReconciliationDetail(db, companyId, reconciliationId);

  if (detail.reconciliation.status !== "open") {
    throw new ApiError(409, "Seuls les rapprochements ouverts peuvent être modifiés.");
  }

  const scopedBankMovements = detail.bank_movements.filter(
    (movement) => bankMovementIds.includes(movement.id),
  );
  const scopedLedgerLines = detail.ledger_lines.filter((line) => entryLineIds.includes(line.id));

  if (scopedBankMovements.length !== bankMovementIds.length) {
    throw new ApiError(400, "Une sélection bancaire est invalide.");
  }

  if (scopedLedgerLines.length !== entryLineIds.length) {
    throw new ApiError(400, "Une sélection comptable est invalide.");
  }

  const bankSelectionAmount = roundAmount(
    scopedBankMovements.reduce((sum, movement) => sum + movement.balance_effect, 0),
  );
  const bookSelectionAmount = roundAmount(
    scopedLedgerLines.reduce((sum, line) => sum + line.balance_effect, 0),
  );

  if (bankSelectionAmount !== bookSelectionAmount) {
    throw new ApiError(
      400,
      "Le total sélectionné sur le relevé bancaire doit être égal au total sélectionné en comptabilité.",
    );
  }

  await db.query(
    `
      UPDATE bank_movements
      SET
        reconciliation_id = NULL,
        is_reconciled = FALSE
      WHERE reconciliation_id = $1
        AND bank_account_id = $2
        AND company_id = $3
        AND NOT (id = ANY($4::bigint[]))
    `,
    [
      reconciliationId,
      detail.reconciliation.bank_account_id,
      companyId,
      bankMovementIds.length ? bankMovementIds : [0],
    ],
  );

  if (bankMovementIds.length) {
    await db.query(
      `
        UPDATE bank_movements
        SET
          reconciliation_id = $1,
          is_reconciled = TRUE
        WHERE id = ANY($2::bigint[])
          AND bank_account_id = $3
          AND company_id = $4
      `,
      [reconciliationId, bankMovementIds, detail.reconciliation.bank_account_id, companyId],
    );
  }

  await db.query("DELETE FROM reconciliation_entry_lines WHERE reconciliation_id = $1", [
    reconciliationId,
  ]);

  for (const entryLineId of entryLineIds) {
    await db.query(
      `
        INSERT INTO reconciliation_entry_lines (
          reconciliation_id,
          entry_line_id
        )
        VALUES ($1, $2)
      `,
      [reconciliationId, entryLineId],
    );
  }

  const updatedDetail = await fetchReconciliationDetail(db, companyId, reconciliationId);

  await db.query(
    `
      UPDATE reconciliations
      SET
        book_balance = $2,
        difference = $3
      WHERE id = $1
    `,
    [
      reconciliationId,
      updatedDetail.summary.book_balance,
      updatedDetail.summary.difference,
    ],
  );

  return fetchReconciliationDetail(db, companyId, reconciliationId);
};

export const cashRegistersController = {
  list: async (req, res) => {
    const companyId = getCompanyId(req);
    const { rows } = await pool.query(
      `
        SELECT
          cr.id,
          cr.company_id,
          cr.name,
          cr.currency,
          cr.current_balance,
          cr.is_active,
          COALESCE(stats.movements_count, 0)::int AS movements_count,
          TO_CHAR(stats.last_movement_date, 'YYYY-MM-DD') AS last_movement_date
        FROM cash_registers cr
        LEFT JOIN LATERAL (
          SELECT
            COUNT(*) AS movements_count,
            MAX(cm.movement_date) AS last_movement_date
          FROM cash_movements cm
          WHERE cm.cash_register_id = cr.id
        ) AS stats
          ON TRUE
        WHERE cr.company_id = $1
        ORDER BY cr.is_active DESC, cr.name ASC
      `,
      [companyId],
    );

    res.json({
      company_id: companyId,
      items: rows.map(mapCashRegisterRow),
    });
  },

  downloadTemplate: async (_req, res) => {
    const workbookBuffer = buildCashRegisterTemplateBuffer();

    res.setHeader(
      "Content-Type",
      "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    );
    res.setHeader(
      "Content-Disposition",
      'attachment; filename="cash-registers-import-template.xlsx"',
    );
    res.send(workbookBuffer);
  },

  importItems: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const mode = getImportMode(req.body.mode);
    const { headers, rows } = readSpreadsheet(req.file);
    const client = await pool.connect();

    try {
      const preview = await buildCashRegisterImportPreview({
        db: client,
        companyId,
        rows,
      });

      if (mode === "preview") {
        return res.json({
          mode,
          headers,
          summary: preview.summary,
          preview_rows: preview.rows.slice(0, TREASURY_IMPORT_PREVIEW_ROWS),
        });
      }

      await client.query("BEGIN");

      const importResult = await commitCashRegisterImport({
        db: client,
        companyId,
        rows: preview.rows,
        userId: req.user?.sub || null,
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      return res.json({
        mode,
        message: "Import caisses terminé.",
        summary: preview.summary,
        ...importResult,
      });
    } catch (error) {
      await client.query("ROLLBACK").catch(() => {});

      if (error?.code === "23505") {
        throw new ApiError(409, "Une caisse avec ce nom existe déjà.");
      }

      throw error;
    } finally {
      client.release();
    }
  },

  getById: async (req, res) => {
    const companyId = getCompanyId(req);
    const registerId = Number(req.params.id);

    if (!registerId) {
      throw new ApiError(400, "Identifiant de caisse invalide.");
    }

    const item = await fetchCashRegisterById(pool, companyId, registerId);

    if (!item) {
      throw new ApiError(404, "Caisse introuvable.");
    }

    res.json({
      item: mapCashRegisterRow(item),
    });
  },

  create: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const client = await pool.connect();

    try {
      await client.query("BEGIN");
      const item = await saveCashRegister({
        db: client,
        companyId,
        body: req.body,
        userId: req.user?.sub || null,
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(201).json({
        message: "Caisse créée avec succès.",
        item,
      });
    } catch (error) {
      await client.query("ROLLBACK");

      if (error?.code === "23505") {
        throw new ApiError(409, "Une caisse avec ce nom existe déjà.");
      }

      throw error;
    } finally {
      client.release();
    }
  },

  update: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const registerId = Number(req.params.id);

    if (!registerId) {
      throw new ApiError(400, "Identifiant de caisse invalide.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const existingItem = await fetchCashRegisterById(client, companyId, registerId);

      if (!existingItem) {
        throw new ApiError(404, "Caisse introuvable.");
      }
      const item = await saveCashRegister({
        db: client,
        companyId,
        body: req.body,
        existingItem,
        userId: req.user?.sub || null,
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.json({
        message: "Caisse mise à jour.",
        item,
      });
    } catch (error) {
      await client.query("ROLLBACK");

      if (error?.code === "23505") {
        throw new ApiError(409, "Une caisse avec ce nom existe déjà.");
      }

      throw error;
    } finally {
      client.release();
    }
  },

  remove: async (req, res) => {
    const companyId = getCompanyId(req);
    const registerId = Number(req.params.id);

    if (!registerId) {
      throw new ApiError(400, "Identifiant de caisse invalide.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const existingItem = await fetchCashRegisterById(client, companyId, registerId);

      if (!existingItem) {
        throw new ApiError(404, "Caisse introuvable.");
      }

      await client.query(
        `
          UPDATE cash_registers
          SET is_active = FALSE
          WHERE id = $1
            AND company_id = $2
        `,
        [registerId, companyId],
      );

      const updatedItem = await fetchCashRegisterById(client, companyId, registerId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "cash_registers.delete",
        entityType: "cash_register",
        entityId: registerId,
        oldData: sanitizeCashRegisterForAudit(mapCashRegisterRow(existingItem)),
        newData: sanitizeCashRegisterForAudit(mapCashRegisterRow(updatedItem)),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.json({
        message: "Caisse désactivée.",
        item: mapCashRegisterRow(updatedItem),
      });
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },
};

export const cashMovementsController = {
  list: async (req, res) => {
    const companyId = getCompanyId(req);
    const registerId = Number(req.params.id);

    if (!registerId) {
      throw new ApiError(400, "Identifiant de caisse invalide.");
    }

    const register = await fetchCashRegisterById(pool, companyId, registerId);

    if (!register) {
      throw new ApiError(404, "Caisse introuvable.");
    }

    const fromDate = req.query.from ? parseDateOnly(req.query.from, "Date début") : null;
    const toDate = req.query.to ? parseDateOnly(req.query.to, "Date fin") : null;
    const direction = req.query.direction
      ? normalizeMovementDirection(req.query.direction, { mode: "cash", required: false })
      : "";
    const search = cleanString(req.query.search);
    const values = [registerId];
    const whereClauses = ["cm.cash_register_id = $1"];

    if (fromDate) {
      values.push(fromDate);
      whereClauses.push(`cm.movement_date >= $${values.length}::date`);
    }

    if (toDate) {
      values.push(toDate);
      whereClauses.push(`cm.movement_date <= $${values.length}::date`);
    }

    if (direction) {
      values.push(direction);
      whereClauses.push(`cm.direction = $${values.length}`);
    }

    if (search) {
      values.push(`%${search}%`);
      whereClauses.push(
        `(cm.piece_ref ILIKE $${values.length} OR cm.label ILIKE $${values.length} OR COALESCE(je.entry_number, '') ILIKE $${values.length})`,
      );
    }

    const balanceContext = await fetchCashOpeningContext(
      pool,
      registerId,
      roundAmount(register.current_balance),
      fromDate,
    );
    const { rows } = await pool.query(
      `
        SELECT
          cm.id,
          TO_CHAR(cm.movement_date, 'YYYY-MM-DD') AS movement_date,
          cm.piece_ref,
          cm.label,
          cm.amount,
          cm.direction,
          cm.entry_id,
          je.entry_number,
          je.piece_ref AS linked_piece_ref
        FROM cash_movements cm
        LEFT JOIN journal_entries je
          ON je.id = cm.entry_id
        WHERE ${whereClauses.join("\nAND ")}
        ORDER BY cm.movement_date ASC, cm.id ASC
      `,
      values,
    );

    res.json({
      register: {
        ...mapCashRegisterRow(register),
        opening_balance: balanceContext.opening_balance,
        formatted_opening_balance: formatSignedBalance(balanceContext.opening_balance),
      },
      filters: {
        from: fromDate,
        to: toDate,
        direction,
        search,
      },
      items: buildCashMovementItems({
        rows,
        startBalance: balanceContext.start_balance,
      }),
    });
  },

  create: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const registerId = Number(req.params.id);

    if (!registerId) {
      throw new ApiError(400, "Identifiant de caisse invalide.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const register = await fetchCashRegisterById(client, companyId, registerId);

      if (!register) {
        throw new ApiError(404, "Caisse introuvable.");
      }

      if (!register.is_active) {
        throw new ApiError(409, "Cette caisse est inactive.");
      }

      const direction = normalizeMovementDirection(req.body.direction, { mode: "cash" });
      const movementDate = parseDateOnly(req.body.movement_date || req.body.date, "Date");
      const label = cleanString(req.body.label);
      const amount = parseAmount(req.body.amount, "Montant");
      const pieceRef = cleanString(req.body.piece_ref || req.body.pieceRef) || null;
      const entryId = Number(req.body.entry_id || 0) || null;

      if (!label) {
        throw new ApiError(400, "Le libellé du mouvement est obligatoire.");
      }

      if (!amount) {
        throw new ApiError(400, "Le montant du mouvement est obligatoire.");
      }

      if (entryId) {
        const linkedEntry = await fetchEntryById(client, companyId, entryId);

        if (!linkedEntry) {
          throw new ApiError(400, "L'écriture comptable liée est introuvable.");
        }
      }

      const { rows } = await client.query(
        `
          INSERT INTO cash_movements (
            company_id,
            cash_register_id,
            movement_date,
            label,
            amount,
            direction,
            piece_ref,
            entry_id
          )
          VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
          RETURNING id
        `,
        [
          companyId,
          registerId,
          movementDate,
          label,
          amount,
          direction,
          pieceRef,
          entryId,
        ],
      );

      await client.query(
        `
          UPDATE cash_registers
          SET current_balance = current_balance + $2
          WHERE id = $1
        `,
        [registerId, getSignedMovementAmount(direction, amount)],
      );

      const movement = await fetchCashMovementById(client, rows[0].id);
      const updatedRegister = await fetchCashRegisterById(client, companyId, registerId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "cash_movements.create",
        entityType: "cash_movement",
        entityId: rows[0].id,
        newData: sanitizeMovementForAudit(movement),
        ipAddress: req.ip,
      });

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "cash_registers.balance",
        entityType: "cash_register",
        entityId: registerId,
        newData: sanitizeCashRegisterForAudit(mapCashRegisterRow(updatedRegister)),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(201).json({
        message: "Mouvement de caisse enregistré.",
        register: mapCashRegisterRow(updatedRegister),
      });
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },
};

export const bankAccountsController = {
  list: async (req, res) => {
    const companyId = getCompanyId(req);
    const { rows } = await pool.query(
      `
        SELECT
          ba.id,
          ba.company_id,
          ba.account_id,
          ba.bank_name,
          ba.account_number,
          ba.rib,
          ba.currency,
          ba.current_balance,
          ba.is_active,
          a.code AS accounting_code,
          a.label AS accounting_label,
          a.balance_normal_side,
          COALESCE(movement_stats.movements_count, 0)::int AS movements_count,
          TO_CHAR(movement_stats.last_movement_date, 'YYYY-MM-DD') AS last_movement_date,
          TO_CHAR(rec_stats.reconciliation_date, 'YYYY-MM-DD') AS last_reconciliation_date,
          rec_stats.status AS last_reconciliation_status
        FROM bank_accounts ba
        LEFT JOIN accounts a
          ON a.id = ba.account_id
        LEFT JOIN LATERAL (
          SELECT
            COUNT(*) AS movements_count,
            MAX(bm.movement_date) AS last_movement_date
          FROM bank_movements bm
          WHERE bm.bank_account_id = ba.id
        ) AS movement_stats
          ON TRUE
        LEFT JOIN LATERAL (
          SELECT reconciliation_date, status
          FROM reconciliations r
          WHERE r.bank_account_id = ba.id
          ORDER BY reconciliation_date DESC, id DESC
          LIMIT 1
        ) AS rec_stats
          ON TRUE
        WHERE ba.company_id = $1
        ORDER BY ba.is_active DESC, ba.bank_name ASC, ba.account_number ASC
      `,
      [companyId],
    );

    res.json({
      company_id: companyId,
      items: rows.map(mapBankAccountRow),
    });
  },

  downloadTemplate: async (_req, res) => {
    const workbookBuffer = buildBankAccountTemplateBuffer();

    res.setHeader(
      "Content-Type",
      "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    );
    res.setHeader(
      "Content-Disposition",
      'attachment; filename="bank-accounts-import-template.xlsx"',
    );
    res.send(workbookBuffer);
  },

  importItems: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const mode = getImportMode(req.body.mode);
    const { headers, rows } = readSpreadsheet(req.file);
    const client = await pool.connect();

    try {
      const preview = await buildBankAccountImportPreview({
        db: client,
        companyId,
        rows,
      });

      if (mode === "preview") {
        return res.json({
          mode,
          headers,
          summary: preview.summary,
          preview_rows: preview.rows.slice(0, TREASURY_IMPORT_PREVIEW_ROWS),
        });
      }

      await client.query("BEGIN");

      const importResult = await commitBankAccountImport({
        db: client,
        companyId,
        rows: preview.rows,
        userId: req.user?.sub || null,
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      return res.json({
        mode,
        message: "Import comptes bancaires terminé.",
        summary: preview.summary,
        ...importResult,
      });
    } catch (error) {
      await client.query("ROLLBACK").catch(() => {});

      if (error?.code === "23505") {
        throw new ApiError(409, "Un compte bancaire avec ce numéro existe déjà.");
      }

      throw error;
    } finally {
      client.release();
    }
  },

  getById: async (req, res) => {
    const companyId = getCompanyId(req);
    const bankAccountId = Number(req.params.id);

    if (!bankAccountId) {
      throw new ApiError(400, "Identifiant de compte bancaire invalide.");
    }

    const item = await fetchBankAccountById(pool, companyId, bankAccountId);

    if (!item) {
      throw new ApiError(404, "Compte bancaire introuvable.");
    }

    res.json({
      item: mapBankAccountRow(item),
    });
  },

  create: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const client = await pool.connect();

    try {
      await client.query("BEGIN");
      const item = await saveBankAccount({
        db: client,
        companyId,
        body: req.body,
        userId: req.user?.sub || null,
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(201).json({
        message: "Compte bancaire créé avec succès.",
        item,
      });
    } catch (error) {
      await client.query("ROLLBACK");

      if (error?.code === "23505") {
        throw new ApiError(409, "Un compte bancaire avec ce numéro existe déjà.");
      }

      throw error;
    } finally {
      client.release();
    }
  },

  update: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const bankAccountId = Number(req.params.id);

    if (!bankAccountId) {
      throw new ApiError(400, "Identifiant de compte bancaire invalide.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const existingItem = await fetchBankAccountById(client, companyId, bankAccountId);

      if (!existingItem) {
        throw new ApiError(404, "Compte bancaire introuvable.");
      }
      const item = await saveBankAccount({
        db: client,
        companyId,
        body: req.body,
        existingItem,
        userId: req.user?.sub || null,
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.json({
        message: "Compte bancaire mis à jour.",
        item,
      });
    } catch (error) {
      await client.query("ROLLBACK");

      if (error?.code === "23505") {
        throw new ApiError(409, "Un compte bancaire avec ce numéro existe déjà.");
      }

      throw error;
    } finally {
      client.release();
    }
  },

  remove: async (req, res) => {
    const companyId = getCompanyId(req);
    const bankAccountId = Number(req.params.id);

    if (!bankAccountId) {
      throw new ApiError(400, "Identifiant de compte bancaire invalide.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const existingItem = await fetchBankAccountById(client, companyId, bankAccountId);

      if (!existingItem) {
        throw new ApiError(404, "Compte bancaire introuvable.");
      }

      await client.query(
        `
          UPDATE bank_accounts
          SET is_active = FALSE
          WHERE id = $1
            AND company_id = $2
        `,
        [bankAccountId, companyId],
      );

      const updatedItem = await fetchBankAccountById(client, companyId, bankAccountId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "bank_accounts.delete",
        entityType: "bank_account",
        entityId: bankAccountId,
        oldData: sanitizeBankAccountForAudit(mapBankAccountRow(existingItem)),
        newData: sanitizeBankAccountForAudit(mapBankAccountRow(updatedItem)),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.json({
        message: "Compte bancaire désactivé.",
        item: mapBankAccountRow(updatedItem),
      });
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },
};

export const bankMovementsController = {
  list: async (req, res) => {
    const companyId = getCompanyId(req);
    const bankAccountId = Number(req.params.id);

    if (!bankAccountId) {
      throw new ApiError(400, "Identifiant de compte bancaire invalide.");
    }

    const bankAccount = await fetchBankAccountById(pool, companyId, bankAccountId);

    if (!bankAccount) {
      throw new ApiError(404, "Compte bancaire introuvable.");
    }

    const fromDate = req.query.from ? parseDateOnly(req.query.from, "Date début") : null;
    const toDate = req.query.to ? parseDateOnly(req.query.to, "Date fin") : null;
    const direction = req.query.direction
      ? normalizeMovementDirection(req.query.direction, { mode: "bank", required: false })
      : "";
    const search = cleanString(req.query.search);
    const values = [bankAccountId];
    const whereClauses = ["bm.bank_account_id = $1"];

    if (fromDate) {
      values.push(fromDate);
      whereClauses.push(`bm.movement_date >= $${values.length}::date`);
    }

    if (toDate) {
      values.push(toDate);
      whereClauses.push(`bm.movement_date <= $${values.length}::date`);
    }

    if (direction) {
      values.push(direction);
      whereClauses.push(`bm.direction = $${values.length}`);
    }

    if (search) {
      values.push(`%${search}%`);
      whereClauses.push(
        `(COALESCE(bm.operation_number, '') ILIKE $${values.length} OR bm.label ILIKE $${values.length} OR COALESCE(je.entry_number, '') ILIKE $${values.length} OR COALESCE(je.piece_ref, '') ILIKE $${values.length})`,
      );
    }

    const balanceContext = await fetchBankOpeningContext(
      pool,
      bankAccountId,
      roundAmount(bankAccount.current_balance),
      fromDate,
    );
    const { rows } = await pool.query(
      `
        SELECT
          bm.id,
          TO_CHAR(bm.movement_date, 'YYYY-MM-DD') AS movement_date,
          bm.operation_number,
          bm.label,
          bm.amount,
          bm.direction,
          bm.is_reconciled,
          bm.reconciliation_id,
          bm.entry_id,
          je.entry_number,
          je.piece_ref AS linked_piece_ref
        FROM bank_movements bm
        LEFT JOIN journal_entries je
          ON je.id = bm.entry_id
        WHERE ${whereClauses.join("\nAND ")}
        ORDER BY bm.movement_date ASC, bm.id ASC
      `,
      values,
    );

    res.json({
      bank_account: {
        ...mapBankAccountRow(bankAccount),
        opening_balance: balanceContext.opening_balance,
        formatted_opening_balance: formatSignedBalance(balanceContext.opening_balance),
      },
      filters: {
        from: fromDate,
        to: toDate,
        direction,
        search,
      },
      items: buildBankMovementItems({
        rows,
        startBalance: balanceContext.start_balance,
      }),
    });
  },

  create: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const bankAccountId = Number(req.params.id);

    if (!bankAccountId) {
      throw new ApiError(400, "Identifiant de compte bancaire invalide.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const bankAccount = await fetchBankAccountById(client, companyId, bankAccountId);

      if (!bankAccount) {
        throw new ApiError(404, "Compte bancaire introuvable.");
      }

      if (!bankAccount.is_active) {
        throw new ApiError(409, "Ce compte bancaire est inactif.");
      }

      const direction = normalizeMovementDirection(req.body.direction, { mode: "bank" });
      const movementDate = parseDateOnly(req.body.movement_date || req.body.date, "Date");
      const label = cleanString(req.body.label);
      const amount = parseAmount(req.body.amount, "Montant");
      const operationNumber =
        cleanString(req.body.operation_number || req.body.operationNumber) || null;
      const entryId = Number(req.body.entry_id || 0) || null;

      if (!label) {
        throw new ApiError(400, "Le libellé du mouvement est obligatoire.");
      }

      if (!amount) {
        throw new ApiError(400, "Le montant du mouvement est obligatoire.");
      }

      if (entryId) {
        const linkedEntry = await fetchEntryById(client, companyId, entryId);

        if (!linkedEntry) {
          throw new ApiError(400, "L'écriture comptable liée est introuvable.");
        }
      }

      const { rows } = await client.query(
        `
          INSERT INTO bank_movements (
            company_id,
            bank_account_id,
            movement_date,
            operation_number,
            label,
            amount,
            direction,
            entry_id
          )
          VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
          RETURNING id
        `,
        [
          companyId,
          bankAccountId,
          movementDate,
          operationNumber,
          label,
          amount,
          direction,
          entryId,
        ],
      );

      await client.query(
        `
          UPDATE bank_accounts
          SET current_balance = current_balance + $2
          WHERE id = $1
        `,
        [bankAccountId, getSignedMovementAmount(direction, amount)],
      );

      const movement = await fetchBankMovementById(client, rows[0].id);
      const updatedAccount = await fetchBankAccountById(client, companyId, bankAccountId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "bank_movements.create",
        entityType: "bank_movement",
        entityId: rows[0].id,
        newData: sanitizeMovementForAudit(movement),
        ipAddress: req.ip,
      });

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "bank_accounts.balance",
        entityType: "bank_account",
        entityId: bankAccountId,
        newData: sanitizeBankAccountForAudit(mapBankAccountRow(updatedAccount)),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(201).json({
        message: "Mouvement bancaire enregistré.",
        bank_account: mapBankAccountRow(updatedAccount),
      });
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },

  importStatement: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const bankAccountId = Number(req.params.id);

    if (!bankAccountId) {
      throw new ApiError(400, "Identifiant de compte bancaire invalide.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const bankAccount = await fetchBankAccountById(client, companyId, bankAccountId);

      if (!bankAccount) {
        throw new ApiError(404, "Compte bancaire introuvable.");
      }

      const spreadsheet = readSpreadsheet(req.file);
      const mapping = resolveStatementMapping(spreadsheet.headers);
      let insertedCount = 0;
      let skippedCount = 0;
      let errorsCount = 0;
      let delta = 0;
      const logLines = [];

      for (const row of spreadsheet.rows) {
        try {
          const movementDate = parseFlexibleDate(row[mapping.date], "Date");
          const label = cleanString(row[mapping.label]);
          const operationNumber = mapping.operation_number
            ? cleanString(row[mapping.operation_number]) || null
            : null;
          const debitAmount = parseAmount(row[mapping.debit], "Débit");
          const creditAmount = parseAmount(row[mapping.credit], "Crédit");

          if (!label) {
            throw new ApiError(400, "Le libellé est obligatoire.");
          }

          if ((debitAmount > 0 && creditAmount > 0) || (debitAmount === 0 && creditAmount === 0)) {
            throw new ApiError(400, "Chaque ligne doit contenir soit un débit soit un crédit.");
          }

          const direction = creditAmount > 0 ? "credit" : "debit";
          const amount = creditAmount > 0 ? creditAmount : debitAmount;
          const { rows: existingRows } = await client.query(
            `
              SELECT id
              FROM bank_movements
              WHERE bank_account_id = $1
                AND movement_date = $2::date
                AND COALESCE(operation_number, '') = COALESCE($3, '')
                AND label = $4
                AND amount = $5
                AND direction = $6
              LIMIT 1
            `,
            [bankAccountId, movementDate, operationNumber, label, amount, direction],
          );

          if (existingRows[0]) {
            skippedCount += 1;
            logLines.push({
              row_number: Number(row.__row_number || 0),
              level: "warning",
              message: "Ligne ignorée car déjà importée.",
            });
            continue;
          }

          await client.query(
            `
              INSERT INTO bank_movements (
                company_id,
                bank_account_id,
                movement_date,
                operation_number,
                label,
                amount,
                direction
              )
              VALUES ($1, $2, $3, $4, $5, $6, $7)
            `,
            [companyId, bankAccountId, movementDate, operationNumber, label, amount, direction],
          );

          insertedCount += 1;
          delta += getSignedMovementAmount(direction, amount);
          logLines.push({
            row_number: Number(row.__row_number || 0),
            level: "success",
            message: `${BANK_DIRECTION_LABELS[direction]} importé.`,
          });
        } catch (error) {
          errorsCount += 1;
          logLines.push({
            row_number: Number(row.__row_number || 0),
            level: "error",
            message: error.message,
          });
        }
      }

      if (insertedCount) {
        await client.query(
          `
            UPDATE bank_accounts
            SET current_balance = current_balance + $2
            WHERE id = $1
          `,
          [bankAccountId, delta],
        );
      }

      const updatedAccount = await fetchBankAccountById(client, companyId, bankAccountId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "bank_movements.import",
        entityType: "bank_account",
        entityId: bankAccountId,
        newData: {
          inserted_count: insertedCount,
          skipped_count: skippedCount,
          errors_count: errorsCount,
        },
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.json({
        message: "Import du relevé terminé.",
        summary: {
          inserted_count: insertedCount,
          skipped_count: skippedCount,
          errors_count: errorsCount,
        },
        bank_account: mapBankAccountRow(updatedAccount),
        log: logLines,
      });
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },
};

export const reconciliationsController = {
  list: async (req, res) => {
    const companyId = getCompanyId(req);
    const bankAccountId = Number(req.query.bank_account_id || 0);

    if (!bankAccountId) {
      throw new ApiError(400, "bank_account_id is required.");
    }

    const bankAccount = await fetchBankAccountById(pool, companyId, bankAccountId);

    if (!bankAccount) {
      throw new ApiError(404, "Compte bancaire introuvable.");
    }

    const { rows } = await pool.query(
      `
        SELECT
          r.id,
          TO_CHAR(r.reconciliation_date, 'YYYY-MM-DD') AS reconciliation_date,
          r.bank_balance,
          r.book_balance,
          r.difference,
          r.status,
          COALESCE(bank_stats.matched_bank_count, 0)::int AS matched_bank_count,
          COALESCE(book_stats.matched_book_count, 0)::int AS matched_book_count
        FROM reconciliations r
        LEFT JOIN LATERAL (
          SELECT COUNT(*) AS matched_bank_count
          FROM bank_movements bm
          WHERE bm.reconciliation_id = r.id
        ) AS bank_stats
          ON TRUE
        LEFT JOIN LATERAL (
          SELECT COUNT(*) AS matched_book_count
          FROM reconciliation_entry_lines rel
          WHERE rel.reconciliation_id = r.id
        ) AS book_stats
          ON TRUE
        WHERE r.company_id = $1
          AND r.bank_account_id = $2
        ORDER BY r.reconciliation_date DESC, r.id DESC
      `,
      [companyId, bankAccountId],
    );

    res.json({
      bank_account: mapBankAccountRow(bankAccount),
      open_reconciliation_id: rows.find((row) => row.status === "open")?.id
        ? Number(rows.find((row) => row.status === "open").id)
        : null,
      items: rows.map((row) => ({
        id: Number(row.id),
        reconciliation_date: row.reconciliation_date,
        bank_balance: roundAmount(row.bank_balance),
        formatted_bank_balance: formatSignedBalance(row.bank_balance),
        book_balance: roundAmount(row.book_balance),
        formatted_book_balance: formatSignedBalance(row.book_balance),
        difference: roundAmount(row.difference),
        formatted_difference: formatSignedBalance(row.difference),
        difference_tone: getBalanceTone(row.difference),
        status: row.status,
        status_label: RECONCILIATION_STATUS_LABELS[row.status] || row.status,
        matched_bank_count: Number(row.matched_bank_count || 0),
        matched_book_count: Number(row.matched_book_count || 0),
      })),
    });
  },

  getById: async (req, res) => {
    const companyId = getCompanyId(req);
    const reconciliationId = Number(req.params.id);

    if (!reconciliationId) {
      throw new ApiError(400, "Identifiant de rapprochement invalide.");
    }

    const detail = await fetchReconciliationDetail(pool, companyId, reconciliationId);

    res.json(detail);
  },

  create: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const bankAccountId = Number(req.body.bank_account_id || 0);

    if (!bankAccountId) {
      throw new ApiError(400, "Le compte bancaire est obligatoire.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const bankAccount = await fetchBankAccountById(client, companyId, bankAccountId);

      if (!bankAccount) {
        throw new ApiError(404, "Compte bancaire introuvable.");
      }

      if (!bankAccount.account_id) {
        throw new ApiError(
          409,
          "Lancez le rapprochement seulement après avoir lié le compte bancaire à un compte comptable.",
        );
      }

      const existingOpenId = await fetchOpenReconciliationByBankAccount(
        client,
        companyId,
        bankAccountId,
      );

      if (existingOpenId) {
        throw new ApiError(
          409,
          "Un rapprochement ouvert existe déjà pour ce compte bancaire.",
        );
      }

      const reconciliationDate = parseDateOnly(
        req.body.reconciliation_date || req.body.date,
        "Date de rapprochement",
      );
      const bankBalance = parseAmount(req.body.bank_balance, "Solde banque");
      const bookBalance = await fetchBookBalanceForReconciliation(client, companyId, {
        bank_account: {
          account_id: Number(bankAccount.account_id),
          balance_normal_side: bankAccount.balance_normal_side || "debit",
        },
        reconciliation_date: reconciliationDate,
      });
      const { rows } = await client.query(
        `
          INSERT INTO reconciliations (
            company_id,
            bank_account_id,
            reconciliation_date,
            bank_balance,
            book_balance,
            difference,
            status
          )
          VALUES ($1, $2, $3, $4, $5, $6, 'open')
          RETURNING id
        `,
        [
          companyId,
          bankAccountId,
          reconciliationDate,
          bankBalance,
          bookBalance,
          roundAmount(bankBalance - bookBalance),
        ],
      );

      const detail = await fetchReconciliationDetail(client, companyId, rows[0].id);

      await client.query(
        `
          UPDATE reconciliations
          SET
            book_balance = $2,
            difference = $3
          WHERE id = $1
        `,
        [rows[0].id, detail.summary.book_balance, detail.summary.difference],
      );

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "reconciliations.create",
        entityType: "reconciliation",
        entityId: rows[0].id,
        newData: {
          bank_account_id: bankAccountId,
          reconciliation_date: reconciliationDate,
          bank_balance: bankBalance,
        },
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(201).json({
        message: "Session de rapprochement créée.",
        ...(await fetchReconciliationDetail(pool, companyId, rows[0].id)),
      });
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },

  update: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const reconciliationId = Number(req.params.id);

    if (!reconciliationId) {
      throw new ApiError(400, "Identifiant de rapprochement invalide.");
    }

    const bankMovementIds = Array.isArray(req.body.bank_movement_ids)
      ? [...new Set(req.body.bank_movement_ids.map((value) => Number(value)).filter(Boolean))]
      : [];
    const entryLineIds = Array.isArray(req.body.entry_line_ids)
      ? [...new Set(req.body.entry_line_ids.map((value) => Number(value)).filter(Boolean))]
      : [];
    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const updatedDetail = await saveReconciliationState(
        client,
        companyId,
        reconciliationId,
        bankMovementIds,
        entryLineIds,
      );

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "reconciliations.save",
        entityType: "reconciliation",
        entityId: reconciliationId,
        newData: {
          matched_bank_movement_ids: bankMovementIds,
          matched_entry_line_ids: entryLineIds,
          difference: updatedDetail.summary.difference,
        },
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      req.app
        .get("io")
        ?.to(`company:${companyId}`)
        .emit("reconciliation:saved", {
          reconciliation_id: reconciliationId,
          bank_account_id: updatedDetail.reconciliation.bank_account_id,
          label: `${updatedDetail.reconciliation.bank_account.bank_name} · ${updatedDetail.reconciliation.reconciliation_date}`,
        });

      const savedNotifications = await createCompanyNotifications(pool, {
        companyId,
        type: "reconciliation_saved",
        message: `Rapprochement sauvegardé: ${updatedDetail.reconciliation.bank_account.bank_name} · ${updatedDetail.reconciliation.reconciliation_date}`,
        entityId: reconciliationId,
      });

      emitNotifications(req.app.get("io"), savedNotifications);

      res.json({
        message: "Rapprochement sauvegardé.",
        ...updatedDetail,
      });
    } catch (error) {
      await client.query("ROLLBACK");

      if (error?.code === "23505") {
        throw new ApiError(
          409,
          "Une des lignes comptables sélectionnées est déjà rattachée à un autre rapprochement.",
        );
      }

      throw error;
    } finally {
      client.release();
    }
  },

  close: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const reconciliationId = Number(req.params.id);

    if (!reconciliationId) {
      throw new ApiError(400, "Identifiant de rapprochement invalide.");
    }

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      const currentDetail = await fetchReconciliationDetail(client, companyId, reconciliationId);

      if (currentDetail.reconciliation.status !== "open") {
        throw new ApiError(409, "Ce rapprochement est déjà clôturé.");
      }

      if (currentDetail.summary.difference !== 0) {
        throw new ApiError(
          409,
          "Le rapprochement ne peut être clôturé que lorsque l'écart est nul.",
        );
      }

      await client.query(
        `
          UPDATE reconciliations
          SET
            status = 'closed',
            book_balance = $2,
            difference = $3
          WHERE id = $1
        `,
        [
          reconciliationId,
          currentDetail.summary.book_balance,
          currentDetail.summary.difference,
        ],
      );

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "reconciliations.close",
        entityType: "reconciliation",
        entityId: reconciliationId,
        newData: {
          status: "closed",
          bank_account_id: currentDetail.reconciliation.bank_account_id,
          reconciliation_date: currentDetail.reconciliation.reconciliation_date,
        },
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      const closedDetail = await fetchReconciliationDetail(pool, companyId, reconciliationId);

      req.app
        .get("io")
        ?.to(`company:${companyId}`)
        .emit("reconciliation:closed", {
          reconciliation_id: reconciliationId,
          bank_account_id: closedDetail.reconciliation.bank_account_id,
          label: `${closedDetail.reconciliation.bank_account.bank_name} · ${closedDetail.reconciliation.reconciliation_date}`,
        });

      const closedNotifications = await createCompanyNotifications(pool, {
        companyId,
        type: "reconciliation_closed",
        message: `Rapprochement clôturé: ${closedDetail.reconciliation.bank_account.bank_name} · ${closedDetail.reconciliation.reconciliation_date}`,
        entityId: reconciliationId,
      });

      emitNotifications(req.app.get("io"), closedNotifications);

      res.json({
        message: "Rapprochement clôturé.",
        ...closedDetail,
      });
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },
};
