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 ENTRY_STATUS_LABELS = {
  draft: "Brouillon",
  validated: "Validée",
  cancelled: "Annulée",
};

const JOURNAL_TYPE_LABELS = {
  purchase: "Achats",
  sale: "Ventes",
  cash: "Caisse",
  bank: "Banque",
  misc: "OD",
  payroll: "Paie",
  assets: "Immobilisations",
};

const TIMELINE_ACTION_LABELS = {
  "entries.create": "Brouillon créé",
  "entries.update": "Brouillon mis à jour",
  "entries.validate": "Écriture validée",
  "entries.reverse": "Contre-passation générée",
  "entries.reverse.cancel": "Écriture annulée par contre-passation",
  "entries.delete": "Brouillon supprimé",
};

const ENTRY_IMPORT_REQUIRED_FIELDS = [
  "date",
  "journal_code",
  "piece_ref",
  "line_label",
  "account_code",
  "auxiliary_code",
  "debit",
  "credit",
];

const MAX_IMPORT_PREVIEW_ROWS = 20;

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 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 parseLimit = (value, fallback = 10) => {
  const parsed = Number(value);

  if (!Number.isFinite(parsed) || parsed <= 0) {
    return fallback;
  }

  return Math.min(parsed, 100);
};

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

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

  if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
    [year, month, day] = normalized.split("-").map(Number);
  } else {
    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]);

    year = rawYear < 100 ? 2000 + rawYear : rawYear;

    if (first > 12 && second <= 12) {
      day = first;
      month = second;
    } else if (second > 12 && first <= 12) {
      month = first;
      day = second;
    } else {
      day = first;
      month = second;
    }
  }

  const isoValue = `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
  const date = new Date(`${isoValue}T00:00:00Z`);

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

  return isoValue;
};

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

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

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

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

  if (!normalized) {
    return 0;
  }

  const amount = Number.parseFloat(normalized);

  if (!Number.isFinite(amount) || amount < 0) {
    throw new ApiError(400, "Montant invalide.");
  }

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

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

  if (!normalized) {
    return "";
  }

  return ["draft", "validated", "cancelled"].includes(normalized) ? normalized : "";
};

const sanitizeEntryForAudit = (entry) => ({
  id: entry.id,
  journal_id: entry.journal_id,
  fiscal_year_id: entry.fiscal_year_id,
  period_id: entry.period_id,
  entry_number: entry.entry_number,
  entry_date: entry.entry_date,
  label: entry.label,
  status: entry.status,
  piece_ref: entry.piece_ref,
  attachment_url: entry.attachment_url,
  total_debit: entry.total_debit,
  total_credit: entry.total_credit,
  line_count: entry.lines?.length || entry.line_count || 0,
});

const sanitizeTemplateForAudit = (template) => ({
  id: template.id,
  name: template.name,
  journal_id: template.journal_id,
  journal_code: template.journal_code,
  lines_count: template.lines?.length || template.lines_count || 0,
});

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

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

  return {
    columns: headerRow,
    rows,
  };
};

const resolveImportColumns = (columns) => {
  const normalizedColumns = new Map(
    columns.map((columnName) => [normalizeKey(columnName), columnName]),
  );
  const mapping = {};

  ENTRY_IMPORT_REQUIRED_FIELDS.forEach((fieldName) => {
    const exactColumn = normalizedColumns.get(fieldName);

    if (!exactColumn) {
      throw new ApiError(
        400,
        `La colonne "${fieldName}" est obligatoire pour l'import des écritures.`,
      );
    }

    mapping[fieldName] = exactColumn;
  });

  return mapping;
};

const fetchJournalById = async (db, companyId, journalId) => {
  const { rows } = await db.query(
    `
      SELECT id, code, label, journal_type, numbering_mode, is_active
      FROM journals
      WHERE id = $1
        AND company_id = $2
      LIMIT 1
    `,
    [journalId, companyId],
  );

  return rows[0] || null;
};

const fetchJournalByCode = async (db, companyId, code) => {
  const { rows } = await db.query(
    `
      SELECT id, code, label, journal_type, numbering_mode, is_active
      FROM journals
      WHERE company_id = $1
        AND UPPER(code) = UPPER($2)
      LIMIT 1
    `,
    [companyId, code],
  );

  return rows[0] || null;
};

const resolveAccountingContext = async (db, companyId, entryDate) => {
  const { rows } = await db.query(
    `
      SELECT
        fy.id AS fiscal_year_id,
        fy.label AS fiscal_year_label,
        fy.is_closed AS fiscal_year_is_closed,
        ap.id AS period_id,
        ap.month,
        ap.year,
        ap.is_closed AS period_is_closed
      FROM fiscal_years fy
      LEFT JOIN accounting_periods ap
        ON ap.fiscal_year_id = fy.id
        AND ap.company_id = fy.company_id
        AND ap.month = EXTRACT(MONTH FROM $2::date)::int
        AND ap.year = EXTRACT(YEAR FROM $2::date)::int
      WHERE fy.company_id = $1
        AND $2::date BETWEEN fy.start_date AND fy.end_date
      ORDER BY fy.is_current DESC, fy.start_date DESC
      LIMIT 1
    `,
    [companyId, entryDate],
  );

  const context = rows[0] || null;

  if (!context?.fiscal_year_id || !context?.period_id) {
    throw new ApiError(
      400,
      "La date de l'écriture doit appartenir à un exercice et une période comptables existants.",
    );
  }

  if (context.fiscal_year_is_closed || context.period_is_closed) {
    throw new ApiError(400, "La date de l'écriture doit appartenir à une période ouverte.");
  }

  return {
    fiscal_year_id: Number(context.fiscal_year_id),
    fiscal_year_label: context.fiscal_year_label,
    period_id: Number(context.period_id),
    period_label: `${String(context.month).padStart(2, "0")}/${context.year}`,
    period_month: Number(context.month),
    period_year: Number(context.year),
  };
};

const buildEntryNumber = async (db, { companyId, journal, fiscalYearId, fiscalYearLabel, manualValue }) => {
  const manualEntryNumber = cleanString(manualValue);

  if (manualEntryNumber) {
    return manualEntryNumber.toUpperCase();
  }

  const fiscalSegment =
    (
      cleanString(fiscalYearLabel).replace(/[^0-9A-Za-z]+/g, "") || String(fiscalYearId)
    ).toUpperCase();
  const prefix = `${cleanString(journal.code).toUpperCase()}-${fiscalSegment}-`;
  const { rows } = await db.query(
    `
      SELECT
        COALESCE(
          MAX(
            NULLIF(SUBSTRING(entry_number FROM '([0-9]+)$'), '')::int
          ),
          0
        ) + 1 AS next_sequence
      FROM journal_entries
      WHERE company_id = $1
        AND journal_id = $2
        AND fiscal_year_id = $3
        AND UPPER(entry_number) LIKE UPPER($4)
    `,
    [companyId, journal.id, fiscalYearId, `${prefix}%`],
  );

  const nextSequence = Number(rows[0]?.next_sequence || 1);

  return `${prefix}${String(nextSequence).padStart(5, "0")}`;
};

const fetchAccountsByIds = async (db, companyId, accountIds) => {
  if (!accountIds.length) {
    return new Map();
  }

  const { rows } = await db.query(
    `
      SELECT
        a.id,
        a.code,
        a.label,
        a.account_type,
        a.is_movable,
        a.is_auxiliary,
        a.is_active,
        a.balance_normal_side,
        COALESCE(
          SUM(
            CASE
              WHEN je.status = 'validated'
                THEN el.debit - el.credit
              ELSE 0
            END
          ),
          0
        )::numeric AS current_balance
      FROM accounts a
      LEFT JOIN entry_lines el
        ON el.account_id = a.id
      LEFT JOIN journal_entries je
        ON je.id = el.entry_id
      WHERE a.company_id = $1
        AND a.id = ANY($2::bigint[])
      GROUP BY a.id, a.code, a.label, a.account_type, a.is_movable, a.is_auxiliary, a.is_active, a.balance_normal_side
    `,
    [companyId, accountIds],
  );

  return new Map(
    rows.map((row) => [
      Number(row.id),
      {
        ...row,
        id: Number(row.id),
        current_balance: toNumber(row.current_balance),
      },
    ]),
  );
};

const fetchAccountsByCodes = async (db, companyId, accountCodes) => {
  if (!accountCodes.length) {
    return new Map();
  }

  const { rows } = await db.query(
    `
      SELECT
        id,
        code,
        label,
        account_type,
        is_movable,
        is_auxiliary,
        is_active,
        balance_normal_side
      FROM accounts
      WHERE company_id = $1
        AND code = ANY($2::text[])
    `,
    [companyId, accountCodes],
  );

  return new Map(rows.map((row) => [row.code, { ...row, id: Number(row.id) }]));
};

const fetchAuxiliariesByIds = async (db, companyId, auxiliaryIds) => {
  if (!auxiliaryIds.length) {
    return new Map();
  }

  const { rows } = await db.query(
    `
      SELECT
        aa.id,
        aa.account_id,
        aa.ref_code,
        aa.name,
        aa.third_party_type
      FROM auxiliary_accounts aa
      WHERE aa.company_id = $1
        AND aa.id = ANY($2::bigint[])
    `,
    [companyId, auxiliaryIds],
  );

  return new Map(
    rows.map((row) => [
      Number(row.id),
      {
        ...row,
        id: Number(row.id),
        account_id: Number(row.account_id),
      },
    ]),
  );
};

const fetchAuxiliariesByCodes = async (db, companyId, auxiliaryCodes) => {
  if (!auxiliaryCodes.length) {
    return new Map();
  }

  const { rows } = await db.query(
    `
      SELECT
        aa.id,
        aa.account_id,
        aa.ref_code,
        aa.name,
        aa.third_party_type
      FROM auxiliary_accounts aa
      WHERE aa.company_id = $1
        AND aa.ref_code = ANY($2::text[])
    `,
    [companyId, auxiliaryCodes],
  );

  return new Map(
    rows.map((row) => [
      row.ref_code,
      {
        ...row,
        id: Number(row.id),
        account_id: Number(row.account_id),
      },
    ]),
  );
};

const normalizeEntryLines = async ({ db, companyId, rawLines }) => {
  if (!Array.isArray(rawLines) || rawLines.length < 2) {
    throw new ApiError(400, "Une écriture doit contenir au minimum 2 lignes.");
  }

  const accountIds = [...new Set(rawLines.map((line) => Number(line.account_id || 0)).filter(Boolean))];
  const auxiliaryIds = [
    ...new Set(rawLines.map((line) => Number(line.auxiliary_account_id || 0)).filter(Boolean)),
  ];
  const accountsMap = await fetchAccountsByIds(db, companyId, accountIds);
  const auxiliariesMap = await fetchAuxiliariesByIds(db, companyId, auxiliaryIds);

  const normalizedLines = rawLines.map((line, index) => {
    const accountId = Number(line.account_id || 0);

    if (!accountId) {
      throw new ApiError(400, `La ligne ${index + 1} doit référencer un compte.`);
    }

    const account = accountsMap.get(accountId);

    if (!account) {
      throw new ApiError(400, `Le compte de la ligne ${index + 1} est introuvable.`);
    }

    if (!account.is_active) {
      throw new ApiError(
        400,
        `Le compte ${account.code} est inactif et ne peut pas être utilisé dans une écriture.`,
      );
    }

    if (!account.is_movable) {
      throw new ApiError(
        400,
        `Le compte ${account.code} est non mouvementable et ne peut pas recevoir de ligne d'écriture.`,
      );
    }

    const auxiliaryAccountId = Number(line.auxiliary_account_id || 0) || null;
    const auxiliary = auxiliaryAccountId ? auxiliariesMap.get(auxiliaryAccountId) : null;

    if (account.is_auxiliary && !auxiliaryAccountId) {
      throw new ApiError(
        400,
        `Le compte ${account.code} nécessite un compte auxiliaire.`,
      );
    }

    if (auxiliaryAccountId && !auxiliary) {
      throw new ApiError(
        400,
        `Le compte auxiliaire de la ligne ${index + 1} est introuvable.`,
      );
    }

    if (auxiliary && Number(auxiliary.account_id) !== Number(account.id)) {
      throw new ApiError(
        400,
        `Le compte auxiliaire ${auxiliary.ref_code} ne correspond pas au compte ${account.code}.`,
      );
    }

    const debit = parseAmount(line.debit);
    const credit = parseAmount(line.credit);

    if ((debit > 0 && credit > 0) || (debit === 0 && credit === 0)) {
      throw new ApiError(
        400,
        `La ligne ${index + 1} doit porter soit un débit soit un crédit.`,
      );
    }

    const dueDate = cleanString(line.due_date)
      ? parseDateInput(line.due_date, "Échéance")
      : null;
    const label = cleanString(line.label) || account.label;

    return {
      account_id: Number(account.id),
      account_code: account.code,
      account_label: account.label,
      account_balance: account.current_balance,
      auxiliary_account_id: auxiliary ? Number(auxiliary.id) : null,
      auxiliary_ref_code: auxiliary?.ref_code || null,
      auxiliary_name: auxiliary?.name || null,
      label,
      debit,
      credit,
      due_date: dueDate,
    };
  });

  const totals = normalizedLines.reduce(
    (accumulator, line) => ({
      totalDebit: Number((accumulator.totalDebit + line.debit).toFixed(2)),
      totalCredit: Number((accumulator.totalCredit + line.credit).toFixed(2)),
    }),
    { totalDebit: 0, totalCredit: 0 },
  );
  const difference = Number((totals.totalDebit - totals.totalCredit).toFixed(2));

  if (difference !== 0) {
    throw new ApiError(
      400,
      "Le total débit doit être strictement égal au total crédit avant validation ou enregistrement.",
    );
  }

  return {
    lines: normalizedLines,
    totals,
    difference,
  };
};

const normalizeTemplateLines = async ({ db, companyId, rawLines }) => {
  if (!Array.isArray(rawLines) || rawLines.length < 2) {
    throw new ApiError(400, "Un modèle doit contenir au minimum 2 lignes.");
  }

  const accountIds = [...new Set(rawLines.map((line) => Number(line.account_id || 0)).filter(Boolean))];
  const auxiliaryIds = [
    ...new Set(rawLines.map((line) => Number(line.auxiliary_account_id || 0)).filter(Boolean)),
  ];
  const accountsMap = await fetchAccountsByIds(db, companyId, accountIds);
  const auxiliariesMap = await fetchAuxiliariesByIds(db, companyId, auxiliaryIds);

  const normalizedLines = rawLines.map((line, index) => {
    const accountId = Number(line.account_id || 0);

    if (!accountId) {
      throw new ApiError(400, `La ligne ${index + 1} doit référencer un compte.`);
    }

    const account = accountsMap.get(accountId);

    if (!account) {
      throw new ApiError(400, `Le compte de la ligne ${index + 1} est introuvable.`);
    }

    if (!account.is_movable) {
      throw new ApiError(
        400,
        `Le compte ${account.code} est non mouvementable et ne peut pas être utilisé dans un modèle.`,
      );
    }

    const auxiliaryAccountId = Number(line.auxiliary_account_id || 0) || null;
    const auxiliary = auxiliaryAccountId ? auxiliariesMap.get(auxiliaryAccountId) : null;

    if (auxiliary && Number(auxiliary.account_id) !== Number(account.id)) {
      throw new ApiError(
        400,
        `Le compte auxiliaire ${auxiliary.ref_code} ne correspond pas au compte ${account.code}.`,
      );
    }

    const debit = parseAmount(line.debit);
    const credit = parseAmount(line.credit);

    if (debit > 0 && credit > 0) {
      throw new ApiError(
        400,
        `La ligne ${index + 1} du modèle ne peut pas contenir un débit et un crédit simultanément.`,
      );
    }

    return {
      account_id: Number(account.id),
      account_code: account.code,
      account_label: account.label,
      auxiliary_account_id: auxiliary ? Number(auxiliary.id) : null,
      auxiliary_ref_code: auxiliary?.ref_code || null,
      auxiliary_name: auxiliary?.name || null,
      label: cleanString(line.label) || account.label,
      debit,
      credit,
    };
  });

  const totals = normalizedLines.reduce(
    (accumulator, line) => ({
      totalDebit: Number((accumulator.totalDebit + line.debit).toFixed(2)),
      totalCredit: Number((accumulator.totalCredit + line.credit).toFixed(2)),
    }),
    { totalDebit: 0, totalCredit: 0 },
  );
  const hasAmounts = totals.totalDebit > 0 || totals.totalCredit > 0;

  if (hasAmounts && Number((totals.totalDebit - totals.totalCredit).toFixed(2)) !== 0) {
    throw new ApiError(
      400,
      "Lorsque des montants sont définis dans un modèle, le total débit doit être égal au total crédit.",
    );
  }

  return {
    lines: normalizedLines,
    totals,
  };
};

const insertEntryLines = async (db, entryId, lines) => {
  for (const line of lines) {
    await db.query(
      `
        INSERT INTO entry_lines (
          entry_id,
          account_id,
          auxiliary_account_id,
          label,
          debit,
          credit,
          due_date
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7)
      `,
      [
        entryId,
        line.account_id,
        line.auxiliary_account_id,
        line.label,
        line.debit,
        line.credit,
        line.due_date,
      ],
    );
  }
};

const fetchEntryRow = async (db, companyId, entryId) => {
  const { rows } = await db.query(
    `
      SELECT
        je.id,
        je.company_id,
        je.journal_id,
        je.fiscal_year_id,
        je.period_id,
        je.entry_number,
        TO_CHAR(je.entry_date, 'YYYY-MM-DD') AS entry_date,
        je.label,
        je.status,
        je.piece_ref,
        je.attachment_url,
        je.created_by,
        je.validated_by,
        je.created_at,
        je.updated_at,
        j.code AS journal_code,
        j.label AS journal_label,
        j.journal_type,
        fy.label AS fiscal_year_label,
        fy.is_closed AS fiscal_year_is_closed,
        ap.month AS period_month,
        ap.year AS period_year,
        ap.is_closed AS period_is_closed,
        creator.full_name AS created_by_name,
        validator.full_name AS validated_by_name,
        COALESCE(totals.total_debit, 0)::numeric AS total_debit,
        COALESCE(totals.total_credit, 0)::numeric AS total_credit,
        COALESCE(totals.line_count, 0)::int AS line_count
      FROM journal_entries je
      JOIN journals j
        ON j.id = je.journal_id
      LEFT JOIN fiscal_years fy
        ON fy.id = je.fiscal_year_id
      LEFT JOIN accounting_periods ap
        ON ap.id = je.period_id
      LEFT JOIN users creator
        ON creator.id = je.created_by
      LEFT JOIN users validator
        ON validator.id = je.validated_by
      LEFT JOIN LATERAL (
        SELECT
          SUM(el.debit) AS total_debit,
          SUM(el.credit) AS total_credit,
          COUNT(*) AS line_count
        FROM entry_lines el
        WHERE el.entry_id = je.id
      ) AS totals
        ON TRUE
      WHERE je.company_id = $1
        AND je.id = $2
      LIMIT 1
    `,
    [companyId, entryId],
  );

  return rows[0] || null;
};

const fetchEntryLines = async (db, entryId) => {
  const { rows } = await db.query(
    `
      SELECT
        el.id,
        el.account_id,
        el.auxiliary_account_id,
        el.label,
        el.debit,
        el.credit,
        TO_CHAR(el.due_date, 'YYYY-MM-DD') AS due_date,
        el.is_lettered,
        el.letter_code,
        a.code AS account_code,
        a.label AS account_label,
        a.is_auxiliary AS account_is_auxiliary,
        aa.ref_code AS auxiliary_ref_code,
        aa.name AS auxiliary_name
      FROM entry_lines el
      JOIN accounts a
        ON a.id = el.account_id
      LEFT JOIN auxiliary_accounts aa
        ON aa.id = el.auxiliary_account_id
      WHERE el.entry_id = $1
      ORDER BY el.id ASC
    `,
    [entryId],
  );

  return rows.map((row) => ({
    id: Number(row.id),
    account_id: Number(row.account_id),
    account_code: row.account_code,
    account_label: row.account_label,
    account_is_auxiliary: row.account_is_auxiliary,
    auxiliary_account_id: row.auxiliary_account_id ? Number(row.auxiliary_account_id) : null,
    auxiliary_ref_code: row.auxiliary_ref_code,
    auxiliary_name: row.auxiliary_name,
    label: row.label,
    debit: toNumber(row.debit),
    credit: toNumber(row.credit),
    formatted_debit: formatDzdAmount(row.debit),
    formatted_credit: formatDzdAmount(row.credit),
    due_date: row.due_date || null,
    is_lettered: row.is_lettered,
    letter_code: row.letter_code,
  }));
};

const fetchEntryHistory = async (db, entryId) => {
  const { rows } = await db.query(
    `
      SELECT
        al.id,
        al.action,
        al.created_at,
        COALESCE(u.full_name, u.email, 'Système') AS actor_name
      FROM audit_log al
      LEFT JOIN users u
        ON u.id = al.user_id
      WHERE al.entity_type = 'journal_entry'
        AND al.entity_id = $1
      ORDER BY al.created_at ASC, al.id ASC
    `,
    [entryId],
  );

  return rows.map((row) => ({
    id: Number(row.id),
    action: row.action,
    label: TIMELINE_ACTION_LABELS[row.action] || row.action,
    actor_name: row.actor_name,
    occurred_at: row.created_at?.toISOString?.() || row.created_at,
  }));
};

const serializeEntry = ({ row, lines = [], history = [] }) => {
  const totalDebit = toNumber(row.total_debit);
  const totalCredit = toNumber(row.total_credit);
  const difference = Number((totalDebit - totalCredit).toFixed(2));

  return {
    id: Number(row.id),
    company_id: Number(row.company_id),
    journal_id: Number(row.journal_id),
    fiscal_year_id: Number(row.fiscal_year_id),
    period_id: row.period_id ? Number(row.period_id) : null,
    entry_number: row.entry_number,
    entry_date: row.entry_date,
    label: row.label,
    status: row.status,
    status_label: ENTRY_STATUS_LABELS[row.status] || row.status,
    piece_ref: row.piece_ref,
    attachment_url: row.attachment_url,
    journal: {
      id: Number(row.journal_id),
      code: row.journal_code,
      label: row.journal_label,
      journal_type: row.journal_type,
      journal_type_label: JOURNAL_TYPE_LABELS[row.journal_type] || row.journal_type,
    },
    fiscal_year: {
      id: Number(row.fiscal_year_id),
      label: row.fiscal_year_label,
      is_closed: row.fiscal_year_is_closed,
    },
    period: row.period_id
      ? {
          id: Number(row.period_id),
          month: Number(row.period_month),
          year: Number(row.period_year),
          label: `${String(row.period_month).padStart(2, "0")}/${row.period_year}`,
          is_closed: row.period_is_closed,
        }
      : null,
    created_by: row.created_by ? Number(row.created_by) : null,
    validated_by: row.validated_by ? Number(row.validated_by) : null,
    created_by_name: row.created_by_name,
    validated_by_name: row.validated_by_name,
    created_at: row.created_at?.toISOString?.() || row.created_at,
    updated_at: row.updated_at?.toISOString?.() || row.updated_at,
    total_debit: totalDebit,
    total_credit: totalCredit,
    formatted_total_debit: formatDzdAmount(totalDebit),
    formatted_total_credit: formatDzdAmount(totalCredit),
    balance_difference: difference,
    formatted_balance_difference: formatDzdAmount(difference),
    is_balanced: difference === 0,
    line_count: Number(row.line_count || lines.length || 0),
    can_edit: row.status === "draft",
    can_delete: row.status === "draft",
    can_validate: row.status === "draft" && difference === 0,
    can_reverse: row.status === "validated",
    lines,
    history,
  };
};

const fetchEntryDetail = async (db, companyId, entryId) => {
  const row = await fetchEntryRow(db, companyId, entryId);

  if (!row) {
    throw new ApiError(404, "Écriture introuvable.");
  }

  const [lines, history] = await Promise.all([
    fetchEntryLines(db, entryId),
    fetchEntryHistory(db, entryId),
  ]);

  return serializeEntry({
    row: {
      ...row,
      line_count: Number(row.line_count),
    },
    lines,
    history,
  });
};

const normalizeEntryPayload = async ({ db, companyId, body, existingEntry = null }) => {
  const journalId = Number(body.journal_id || existingEntry?.journal_id || 0);

  if (!journalId) {
    throw new ApiError(400, "Le journal est obligatoire.");
  }

  const journal = await fetchJournalById(db, companyId, journalId);

  if (!journal) {
    throw new ApiError(400, "Le journal sélectionné est introuvable.");
  }

  if (!journal.is_active) {
    throw new ApiError(400, "Le journal sélectionné est inactif.");
  }

  const entryDate = parseDateInput(body.entry_date || existingEntry?.entry_date, "Date");
  const accountingContext = await resolveAccountingContext(db, companyId, entryDate);
  const label = cleanString(body.label || existingEntry?.label);
  const pieceRef = cleanString(body.piece_ref || body.pieceRef || existingEntry?.piece_ref);
  const attachmentUrl = cleanString(
    body.attachment_url || body.attachmentUrl || existingEntry?.attachment_url,
  );
  const manualEntryNumber = cleanString(body.entry_number || body.entryNumber);
  const rawLines = Array.isArray(body.lines) ? body.lines : null;

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

  if (!pieceRef) {
    throw new ApiError(400, "Le numéro de pièce est obligatoire.");
  }

  if (!rawLines?.length) {
    throw new ApiError(400, "Les lignes d'écriture sont obligatoires.");
  }

  const normalized = await normalizeEntryLines({
    db,
    companyId,
    rawLines,
  });
  const currentEntryNumber = cleanString(existingEntry?.entry_number);
  const shouldRegenerateEntryNumber =
    !existingEntry ||
    Number(existingEntry.journal_id) !== Number(journal.id) ||
    Number(existingEntry.fiscal_year_id) !== Number(accountingContext.fiscal_year_id) ||
    Boolean(manualEntryNumber);
  const entryNumber = shouldRegenerateEntryNumber
    ? await buildEntryNumber(db, {
        companyId,
        journal,
        fiscalYearId: accountingContext.fiscal_year_id,
        fiscalYearLabel: accountingContext.fiscal_year_label,
        manualValue: manualEntryNumber,
      })
    : currentEntryNumber;

  return {
    journal,
    accountingContext,
    entry_number: entryNumber,
    entry_date: entryDate,
    label,
    piece_ref: pieceRef,
    attachment_url: attachmentUrl || null,
    lines: normalized.lines,
    totals: normalized.totals,
  };
};

const createEntryRecord = async (
  db,
  {
    companyId,
    journalId,
    fiscalYearId,
    periodId,
    entryNumber,
    entryDate,
    label,
    pieceRef,
    attachmentUrl,
    createdBy,
    validatedBy = null,
    status = "draft",
  },
) => {
  const { rows } = await db.query(
    `
      INSERT INTO journal_entries (
        company_id,
        journal_id,
        fiscal_year_id,
        period_id,
        entry_number,
        entry_date,
        label,
        status,
        piece_ref,
        attachment_url,
        created_by,
        validated_by
      )
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
      RETURNING id
    `,
    [
      companyId,
      journalId,
      fiscalYearId,
      periodId,
      entryNumber,
      entryDate,
      label,
      status,
      pieceRef,
      attachmentUrl,
      createdBy,
      validatedBy,
    ],
  );

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

const ensureDraftEntry = async (db, companyId, entryId) => {
  const row = await fetchEntryRow(db, companyId, entryId);

  if (!row) {
    throw new ApiError(404, "Écriture introuvable.");
  }

  if (row.status !== "draft") {
    throw new ApiError(
      409,
      "Seules les écritures en brouillon peuvent être modifiées ou supprimées.",
    );
  }

  return row;
};

const buildImportRows = ({ rows, mapping }) =>
  rows.map((row) => ({
    row_number: Number(row.__row_number || 0),
    date: cleanString(row[mapping.date]),
    journal_code: cleanString(row[mapping.journal_code]).toUpperCase(),
    piece_ref: cleanString(row[mapping.piece_ref]),
    line_label: cleanString(row[mapping.line_label]),
    account_code: cleanString(row[mapping.account_code]),
    auxiliary_code: cleanString(row[mapping.auxiliary_code]),
    debit: cleanString(row[mapping.debit]),
    credit: cleanString(row[mapping.credit]),
  }));

const validateImportedRows = async ({ db, companyId, importedRows }) => {
  const journalCodes = [...new Set(importedRows.map((row) => row.journal_code).filter(Boolean))];
  const accountCodes = [...new Set(importedRows.map((row) => row.account_code).filter(Boolean))];
  const auxiliaryCodes = [
    ...new Set(importedRows.map((row) => row.auxiliary_code).filter(Boolean)),
  ];
  const journalsMap = new Map();

  for (const code of journalCodes) {
    const journal = await fetchJournalByCode(db, companyId, code);

    if (journal) {
      journalsMap.set(code, journal);
    }
  }

  const accountsMap = await fetchAccountsByCodes(db, companyId, accountCodes);
  const auxiliariesMap = await fetchAuxiliariesByCodes(db, companyId, auxiliaryCodes);
  const previewRows = importedRows.map((row) => ({
    ...row,
    errors: [],
  }));
  const groups = new Map();

  for (const row of previewRows) {
    if (!row.date) {
      row.errors.push("Date obligatoire.");
    } else {
      try {
        row.date = parseDateInput(row.date, "Date");
      } catch (error) {
        row.errors.push(error.message);
      }
    }

    if (!row.journal_code) {
      row.errors.push("Code journal obligatoire.");
    } else if (!journalsMap.get(row.journal_code)) {
      row.errors.push(`Journal ${row.journal_code} introuvable.`);
    } else if (!journalsMap.get(row.journal_code)?.is_active) {
      row.errors.push(`Journal ${row.journal_code} inactif.`);
    }

    if (!row.piece_ref) {
      row.errors.push("Numéro de pièce obligatoire.");
    }

    if (!row.account_code) {
      row.errors.push("Code compte obligatoire.");
    } else if (!accountsMap.get(row.account_code)) {
      row.errors.push(`Compte ${row.account_code} introuvable.`);
    } else if (!accountsMap.get(row.account_code)?.is_movable) {
      row.errors.push(`Compte ${row.account_code} non mouvementable.`);
    }

    let debit = 0;
    let credit = 0;

    try {
      debit = parseAmount(row.debit);
      credit = parseAmount(row.credit);
    } catch (error) {
      row.errors.push(error.message);
    }

    if ((debit > 0 && credit > 0) || (debit === 0 && credit === 0)) {
      row.errors.push("Une seule colonne débit ou crédit doit être renseignée.");
    }

    row.debit_value = debit;
    row.credit_value = credit;

    const account = accountsMap.get(row.account_code);
    const auxiliary = row.auxiliary_code ? auxiliariesMap.get(row.auxiliary_code) : null;

    if (account?.is_auxiliary && row.auxiliary_code && !auxiliary) {
      row.errors.push(`Compte auxiliaire ${row.auxiliary_code} introuvable.`);
    }

    if (account?.is_auxiliary && !row.auxiliary_code) {
      row.errors.push(`Le compte ${row.account_code} nécessite un auxiliaire.`);
    }

    if (auxiliary && Number(auxiliary.account_id) !== Number(account?.id)) {
      row.errors.push(
        `L'auxiliaire ${row.auxiliary_code} ne correspond pas au compte ${row.account_code}.`,
      );
    }

    if (!row.errors.length) {
      try {
        const accountingContext = await resolveAccountingContext(db, companyId, row.date);
        row.fiscal_year_id = accountingContext.fiscal_year_id;
        row.period_id = accountingContext.period_id;
      } catch (error) {
        row.errors.push(error.message);
      }
    }

    const groupKey = `${row.date}|${row.journal_code}|${row.piece_ref}`;
    const group = groups.get(groupKey) || {
      key: groupKey,
      date: row.date,
      journal_code: row.journal_code,
      piece_ref: row.piece_ref,
      rows: [],
      errors: [],
    };

    group.rows.push(row);
    groups.set(groupKey, group);
  }

  for (const group of groups.values()) {
    const rowErrors = group.rows.flatMap((row) => row.errors);

    if (rowErrors.length) {
      continue;
    }

    if (group.rows.length < 2) {
      group.errors.push("Une écriture importée doit contenir au minimum 2 lignes.");
    }

    const totalDebit = group.rows.reduce((sum, row) => sum + row.debit_value, 0);
    const totalCredit = group.rows.reduce((sum, row) => sum + row.credit_value, 0);

    if (Number((totalDebit - totalCredit).toFixed(2)) !== 0) {
      group.errors.push("Le groupe importé n'est pas équilibré.");
    }

    if (group.errors.length) {
      group.rows.forEach((row) => {
        row.errors.push(...group.errors);
      });
    }
  }

  return {
    previewRows,
    groups: [...groups.values()].map((group) => ({
      ...group,
      is_valid: group.rows.every((row) => !row.errors.length),
    })),
  };
};

export const entriesController = {
  list: async (req, res) => {
    const companyId = getCompanyId(req);
    const limit = parseLimit(req.query.limit, 50);
    const status = normalizeEntryStatus(req.query.status);
    const journalId = Number(req.query.journal_id || 0) || null;
    const periodId = Number(req.query.period_id || 0) || null;
    const search = cleanString(req.query.search);
    const dateFrom = req.query.date_from ? parseDateInput(req.query.date_from, "Date début") : null;
    const dateTo = req.query.date_to ? parseDateInput(req.query.date_to, "Date fin") : null;
    const values = [companyId];
    const whereClauses = ["je.company_id = $1"];

    if (journalId) {
      values.push(journalId);
      whereClauses.push(`je.journal_id = $${values.length}`);
    }

    if (status) {
      values.push(status);
      whereClauses.push(`je.status = $${values.length}`);
    }

    if (dateFrom) {
      values.push(dateFrom);
      whereClauses.push(`je.entry_date >= $${values.length}::date`);
    }

    if (dateTo) {
      values.push(dateTo);
      whereClauses.push(`je.entry_date <= $${values.length}::date`);
    }

    if (periodId) {
      values.push(periodId);
      whereClauses.push(`je.period_id = $${values.length}`);
    }

    if (search) {
      values.push(`%${search}%`);
      whereClauses.push(`
        (
          je.piece_ref ILIKE $${values.length}
          OR je.label ILIKE $${values.length}
          OR EXISTS (
            SELECT 1
            FROM entry_lines search_lines
            JOIN accounts search_accounts
              ON search_accounts.id = search_lines.account_id
            WHERE search_lines.entry_id = je.id
              AND (
                search_accounts.code ILIKE $${values.length}
                OR search_accounts.label ILIKE $${values.length}
              )
          )
        )
      `);
    }

    values.push(limit);
    const { rows } = await pool.query(
      `
        SELECT
          je.id,
          je.entry_number,
          TO_CHAR(je.entry_date, 'YYYY-MM-DD') AS entry_date,
          je.label,
          je.status,
          je.piece_ref,
          je.attachment_url,
          je.period_id,
          j.id AS journal_id,
          j.code AS journal_code,
          j.label AS journal_label,
          j.journal_type,
          ap.month AS period_month,
          ap.year AS period_year,
          COALESCE(SUM(el.debit), 0)::numeric AS total_debit,
          COALESCE(SUM(el.credit), 0)::numeric AS total_credit,
          COUNT(el.id)::int AS line_count
        FROM journal_entries je
        JOIN journals j
          ON j.id = je.journal_id
        LEFT JOIN accounting_periods ap
          ON ap.id = je.period_id
        LEFT JOIN entry_lines el
          ON el.entry_id = je.id
        WHERE ${whereClauses.join("\nAND ")}
        GROUP BY je.id, j.id, ap.id
        ORDER BY je.entry_date DESC, je.id DESC
        LIMIT $${values.length}
      `,
      values,
    );

    res.json({
      company_id: companyId,
      items: rows.map((row) => {
        const totalDebit = toNumber(row.total_debit);
        const totalCredit = toNumber(row.total_credit);

        return {
          id: Number(row.id),
          entry_number: row.entry_number,
          entry_date: row.entry_date,
          label: row.label,
          status: row.status,
          status_label: ENTRY_STATUS_LABELS[row.status] || row.status,
          piece_ref: row.piece_ref,
          attachment_url: row.attachment_url,
          has_attachment: Boolean(row.attachment_url),
          journal_id: Number(row.journal_id),
          journal_code: row.journal_code,
          journal_label: row.journal_label,
          journal_type: row.journal_type,
          journal_type_label: JOURNAL_TYPE_LABELS[row.journal_type] || row.journal_type,
          period_id: row.period_id ? Number(row.period_id) : null,
          period_label: row.period_id
            ? `${String(row.period_month).padStart(2, "0")}/${row.period_year}`
            : null,
          total_debit: totalDebit,
          total_credit: totalCredit,
          formatted_total_debit: formatDzdAmount(totalDebit),
          formatted_total_credit: formatDzdAmount(totalCredit),
          line_count: Number(row.line_count),
          can_edit: row.status === "draft",
          can_validate: row.status === "draft" && totalDebit === totalCredit,
          can_delete: row.status === "draft",
          can_reverse: row.status === "validated",
        };
      }),
    });
  },

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

    if (!entryId) {
      throw new ApiError(400, "Identifiant d'écriture invalide.");
    }

    const item = await fetchEntryDetail(pool, companyId, entryId);

    res.json({ item });
  },

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

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

      const payload = await normalizeEntryPayload({
        db: client,
        companyId,
        body: req.body,
      });
      const entryId = await createEntryRecord(client, {
        companyId,
        journalId: Number(payload.journal.id),
        fiscalYearId: payload.accountingContext.fiscal_year_id,
        periodId: payload.accountingContext.period_id,
        entryNumber: payload.entry_number,
        entryDate: payload.entry_date,
        label: payload.label,
        pieceRef: payload.piece_ref,
        attachmentUrl: payload.attachment_url,
        createdBy: req.user?.sub || null,
        status: "draft",
      });

      await insertEntryLines(client, entryId, payload.lines);

      const savedEntry = await fetchEntryDetail(client, companyId, entryId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entries.create",
        entityType: "journal_entry",
        entityId: entryId,
        newData: sanitizeEntryForAudit(savedEntry),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

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

      if (error?.code === "23505") {
        throw new ApiError(
          409,
          "Le numéro d'écriture existe déjà pour ce journal et cet exercice.",
        );
      }

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

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

    if (!entryId) {
      throw new ApiError(400, "Identifiant d'écriture invalide.");
    }

    const client = await pool.connect();

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

      const existingEntry = await ensureDraftEntry(client, companyId, entryId);
      const oldEntry = await fetchEntryDetail(client, companyId, entryId);
      const payload = await normalizeEntryPayload({
        db: client,
        companyId,
        body: req.body,
        existingEntry,
      });

      await client.query(
        `
          UPDATE journal_entries
          SET
            journal_id = $2,
            fiscal_year_id = $3,
            period_id = $4,
            entry_number = $5,
            entry_date = $6,
            label = $7,
            piece_ref = $8,
            attachment_url = $9,
            updated_at = CURRENT_TIMESTAMP
          WHERE id = $1
            AND company_id = $10
        `,
        [
          entryId,
          Number(payload.journal.id),
          payload.accountingContext.fiscal_year_id,
          payload.accountingContext.period_id,
          payload.entry_number,
          payload.entry_date,
          payload.label,
          payload.piece_ref,
          payload.attachment_url,
          companyId,
        ],
      );

      await client.query("DELETE FROM entry_lines WHERE entry_id = $1", [entryId]);
      await insertEntryLines(client, entryId, payload.lines);

      const updatedEntry = await fetchEntryDetail(client, companyId, entryId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entries.update",
        entityType: "journal_entry",
        entityId: entryId,
        oldData: sanitizeEntryForAudit(oldEntry),
        newData: sanitizeEntryForAudit(updatedEntry),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

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

      if (error?.code === "23505") {
        throw new ApiError(
          409,
          "Le numéro d'écriture existe déjà pour ce journal et cet exercice.",
        );
      }

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

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

    if (!entryId) {
      throw new ApiError(400, "Identifiant d'écriture invalide.");
    }

    const client = await pool.connect();

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

      const existingEntry = await ensureDraftEntry(client, companyId, entryId);
      const payload = await normalizeEntryPayload({
        db: client,
        companyId,
        body: {
          ...existingEntry,
          lines: await fetchEntryLines(client, entryId),
        },
        existingEntry,
      });

      await client.query(
        `
          UPDATE journal_entries
          SET
            journal_id = $2,
            fiscal_year_id = $3,
            period_id = $4,
            entry_number = $5,
            entry_date = $6,
            label = $7,
            piece_ref = $8,
            attachment_url = $9,
            status = 'validated',
            validated_by = $10,
            updated_at = CURRENT_TIMESTAMP
          WHERE id = $1
            AND company_id = $11
        `,
        [
          entryId,
          Number(payload.journal.id),
          payload.accountingContext.fiscal_year_id,
          payload.accountingContext.period_id,
          payload.entry_number,
          payload.entry_date,
          payload.label,
          payload.piece_ref,
          payload.attachment_url,
          req.user?.sub || null,
          companyId,
        ],
      );

      const validatedEntry = await fetchEntryDetail(client, companyId, entryId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entries.validate",
        entityType: "journal_entry",
        entityId: entryId,
        oldData: sanitizeEntryForAudit(existingEntry),
        newData: sanitizeEntryForAudit(validatedEntry),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      req.app
        .get("io")
        ?.to(`company:${companyId}`)
        .emit("entry:validated", {
          entry_id: entryId,
          journal: validatedEntry.journal.code,
          amount: validatedEntry.formatted_total_debit,
          user: req.user?.fullName || req.user?.email || null,
          label: `${validatedEntry.journal.code} · ${validatedEntry.piece_ref}`,
        });

      const notifications = await createCompanyNotifications(pool, {
        companyId,
        type: "entry_validated",
        message: `Écriture #${validatedEntry.entry_number || validatedEntry.piece_ref || entryId} validée par ${
          req.user?.fullName || req.user?.email || "Utilisateur"
        }`,
        entityId: entryId,
      });

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

      res.json({
        message: "Écriture validée.",
        item: validatedEntry,
      });
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },

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

    if (!entryId) {
      throw new ApiError(400, "Identifiant d'écriture invalide.");
    }

    const client = await pool.connect();

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

      const sourceEntry = await fetchEntryDetail(client, companyId, entryId);

      if (sourceEntry.status !== "validated") {
        throw new ApiError(409, "Seules les écritures validées peuvent être contre-passées.");
      }

      const reversalDate = parseDateInput(
        req.body?.entry_date || formatDateOnly(new Date()),
        "Date de contre-passation",
      );
      const accountingContext = await resolveAccountingContext(client, companyId, reversalDate);
      const journal = await fetchJournalById(client, companyId, sourceEntry.journal_id);
      const reversalEntryNumber = await buildEntryNumber(client, {
        companyId,
        journal,
        fiscalYearId: accountingContext.fiscal_year_id,
        fiscalYearLabel: accountingContext.fiscal_year_label,
        manualValue: cleanString(req.body?.entry_number),
      });
      const reversalLines = sourceEntry.lines.map((line) => ({
        account_id: line.account_id,
        auxiliary_account_id: line.auxiliary_account_id,
        label: `Contre-passation · ${line.label}`,
        debit: line.credit,
        credit: line.debit,
        due_date: line.due_date,
      }));
      const reversalEntryId = await createEntryRecord(client, {
        companyId,
        journalId: sourceEntry.journal_id,
        fiscalYearId: accountingContext.fiscal_year_id,
        periodId: accountingContext.period_id,
        entryNumber: reversalEntryNumber,
        entryDate: reversalDate,
        label: `Contre-passation ${sourceEntry.entry_number}`,
        pieceRef:
          cleanString(req.body?.piece_ref) ||
          `REV-${sourceEntry.piece_ref || sourceEntry.entry_number}`,
        attachmentUrl: null,
        createdBy: req.user?.sub || null,
        validatedBy: req.user?.sub || null,
        status: "validated",
      });

      await insertEntryLines(client, reversalEntryId, reversalLines);

      await client.query(
        `
          UPDATE journal_entries
          SET
            status = 'cancelled',
            updated_at = CURRENT_TIMESTAMP
          WHERE id = $1
            AND company_id = $2
        `,
        [entryId, companyId],
      );

      const cancelledSource = await fetchEntryDetail(client, companyId, entryId);
      const reversalEntry = await fetchEntryDetail(client, companyId, reversalEntryId);

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entries.reverse.cancel",
        entityType: "journal_entry",
        entityId: entryId,
        oldData: sanitizeEntryForAudit(sourceEntry),
        newData: sanitizeEntryForAudit(cancelledSource),
        ipAddress: req.ip,
      });
      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entries.reverse",
        entityType: "journal_entry",
        entityId: reversalEntryId,
        newData: {
          ...sanitizeEntryForAudit(reversalEntry),
          source_entry_id: sourceEntry.id,
          source_entry_number: sourceEntry.entry_number,
        },
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      req.app
        .get("io")
        ?.to(`company:${companyId}`)
        .emit("entry:validated", {
          entry_id: reversalEntryId,
          journal: reversalEntry.journal.code,
          amount: reversalEntry.formatted_total_debit,
          user: req.user?.fullName || req.user?.email || null,
          label: `${reversalEntry.journal.code} · ${reversalEntry.piece_ref}`,
        });

      const notifications = await createCompanyNotifications(pool, {
        companyId,
        type: "entry_validated",
        message: `Écriture #${
          reversalEntry.entry_number || reversalEntry.piece_ref || reversalEntryId
        } validée par ${req.user?.fullName || req.user?.email || "Utilisateur"}`,
        entityId: reversalEntryId,
      });

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

      res.status(201).json({
        message: "Contre-passation générée et validée.",
        item: reversalEntry,
        source_entry: cancelledSource,
      });
    } catch (error) {
      await client.query("ROLLBACK");

      if (error?.code === "23505") {
        throw new ApiError(
          409,
          "Le numéro de contre-passation existe déjà pour ce journal et cet exercice.",
        );
      }

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

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

    if (!entryId) {
      throw new ApiError(400, "Identifiant d'écriture invalide.");
    }

    const client = await pool.connect();

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

      const existingEntry = await ensureDraftEntry(client, companyId, entryId);
      const auditSnapshot = await fetchEntryDetail(client, companyId, entryId);

      await client.query(
        `
          DELETE FROM journal_entries
          WHERE id = $1
            AND company_id = $2
        `,
        [entryId, companyId],
      );

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entries.delete",
        entityType: "journal_entry",
        entityId: entryId,
        oldData: sanitizeEntryForAudit({
          ...existingEntry,
          lines: auditSnapshot.lines,
          total_debit: auditSnapshot.total_debit,
          total_credit: auditSnapshot.total_credit,
        }),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(204).send();
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },

  importEntries: async (req, res) => {
    const companyId = getCompanyId(req, req.body);
    const mode = cleanString(req.body.mode).toLowerCase() || "preview";
    const { columns, rows } = readSpreadsheet(req.file);
    const mapping = resolveImportColumns(columns);
    const importedRows = buildImportRows({
      rows,
      mapping,
    });
    const client = await pool.connect();

    try {
      const validation = await validateImportedRows({
        db: client,
        companyId,
        importedRows,
      });
      const summary = {
        total_rows: validation.previewRows.length,
        valid_rows: validation.previewRows.filter((row) => !row.errors.length).length,
        error_rows: validation.previewRows.filter((row) => row.errors.length).length,
        groups_count: validation.groups.length,
        valid_groups: validation.groups.filter((group) => group.is_valid).length,
      };

      if (mode !== "commit") {
        res.json({
          mode: "preview",
          columns,
          summary,
          preview_rows: validation.previewRows.slice(0, MAX_IMPORT_PREVIEW_ROWS),
        });
        return;
      }

      await client.query("BEGIN");

      let createdCount = 0;
      const createdEntries = [];
      const errors = [];

      for (const group of validation.groups) {
        if (!group.is_valid) {
          errors.push({
            group_key: group.key,
            piece_ref: group.piece_ref,
            messages: [...new Set(group.rows.flatMap((row) => row.errors))],
          });
          continue;
        }

        const groupJournal = await fetchJournalByCode(client, companyId, group.journal_code);
        const accountingContext = await resolveAccountingContext(client, companyId, group.date);
        const entryNumber = await buildEntryNumber(client, {
          companyId,
          journal: groupJournal,
          fiscalYearId: accountingContext.fiscal_year_id,
          fiscalYearLabel: accountingContext.fiscal_year_label,
          manualValue: "",
        });

        await client.query("SAVEPOINT entries_import_group");

        try {
          const accountsMap = await fetchAccountsByCodes(
            client,
            companyId,
            [...new Set(group.rows.map((row) => row.account_code))],
          );
          const auxiliariesMap = await fetchAuxiliariesByCodes(
            client,
            companyId,
            [...new Set(group.rows.map((row) => row.auxiliary_code).filter(Boolean))],
          );
          const entryLines = group.rows.map((row) => {
            const account = accountsMap.get(row.account_code);
            const auxiliary = row.auxiliary_code
              ? auxiliariesMap.get(row.auxiliary_code)
              : null;

            return {
              account_id: Number(account.id),
              auxiliary_account_id: auxiliary ? Number(auxiliary.id) : null,
              label: row.line_label || account.label,
              debit: row.debit_value,
              credit: row.credit_value,
              due_date: null,
            };
          });
          const entryId = await createEntryRecord(client, {
            companyId,
            journalId: Number(groupJournal.id),
            fiscalYearId: accountingContext.fiscal_year_id,
            periodId: accountingContext.period_id,
            entryNumber,
            entryDate: group.date,
            label:
              cleanString(group.rows[0]?.line_label) ||
              `Import ${group.journal_code} ${group.piece_ref}`,
            pieceRef: group.piece_ref,
            attachmentUrl: null,
            createdBy: req.user?.sub || null,
            status: "draft",
          });

          await insertEntryLines(client, entryId, entryLines);

          const savedEntry = await fetchEntryDetail(client, companyId, entryId);

          await logAudit(client, {
            companyId,
            userId: req.user?.sub || null,
            action: "entries.create",
            entityType: "journal_entry",
            entityId: entryId,
            newData: sanitizeEntryForAudit(savedEntry),
            ipAddress: req.ip,
          });

          createdEntries.push(savedEntry);
          createdCount += 1;
        } catch (error) {
          await client.query("ROLLBACK TO SAVEPOINT entries_import_group");
          errors.push({
            group_key: group.key,
            piece_ref: group.piece_ref,
            messages: [error.message || "Erreur inconnue pendant l'import."],
          });
        }
      }

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entries.import",
        entityType: "journal_entry_import",
        newData: {
          created_count: createdCount,
          error_count: errors.length,
        },
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(201).json({
        mode: "commit",
        created_count: createdCount,
        error_count: errors.length,
        entries: createdEntries,
        errors,
      });
    } catch (error) {
      try {
        await client.query("ROLLBACK");
      } catch {
        // no-op: preview mode may not have opened a transaction
      }
      throw error;
    } finally {
      client.release();
    }
  },
};

export const entryTemplatesController = {
  list: async (req, res) => {
    const companyId = getCompanyId(req);
    const { rows } = await pool.query(
      `
        SELECT
          et.id,
          et.name,
          et.journal_id,
          et.lines,
          j.code AS journal_code,
          j.label AS journal_label,
          j.journal_type
        FROM entry_templates et
        JOIN journals j
          ON j.id = et.journal_id
        WHERE et.company_id = $1
        ORDER BY et.name ASC
      `,
      [companyId],
    );

    res.json({
      company_id: companyId,
      items: rows.map((row) => ({
        id: Number(row.id),
        name: row.name,
        journal_id: Number(row.journal_id),
        journal_code: row.journal_code,
        journal_label: row.journal_label,
        journal_type: row.journal_type,
        journal_type_label: JOURNAL_TYPE_LABELS[row.journal_type] || row.journal_type,
        lines_count: Array.isArray(row.lines) ? row.lines.length : 0,
        lines: row.lines || [],
      })),
    });
  },

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

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

      const name = cleanString(req.body.name);
      const journalId = Number(req.body.journal_id || 0);

      if (!name) {
        throw new ApiError(400, "Le nom du modèle est obligatoire.");
      }

      if (!journalId) {
        throw new ApiError(400, "Le journal du modèle est obligatoire.");
      }

      const journal = await fetchJournalById(client, companyId, journalId);

      if (!journal) {
        throw new ApiError(400, "Le journal du modèle est introuvable.");
      }

      const normalized = await normalizeTemplateLines({
        db: client,
        companyId,
        rawLines: req.body.lines,
      });
      const { rows } = await client.query(
        `
          INSERT INTO entry_templates (
            company_id,
            name,
            journal_id,
            lines
          )
          VALUES ($1, $2, $3, $4::jsonb)
          RETURNING id, name, journal_id, lines
        `,
        [companyId, name, journalId, JSON.stringify(normalized.lines)],
      );

      const savedTemplate = {
        id: Number(rows[0].id),
        name: rows[0].name,
        journal_id: Number(rows[0].journal_id),
        journal_code: journal.code,
        lines: rows[0].lines || [],
      };

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entry_templates.create",
        entityType: "entry_template",
        entityId: savedTemplate.id,
        newData: sanitizeTemplateForAudit({
          ...savedTemplate,
          lines_count: savedTemplate.lines.length,
        }),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(201).json({
        message: "Modèle enregistré.",
        item: {
          ...savedTemplate,
          journal_label: journal.label,
          journal_type: journal.journal_type,
          journal_type_label: JOURNAL_TYPE_LABELS[journal.journal_type] || journal.journal_type,
          lines_count: savedTemplate.lines.length,
        },
      });
    } catch (error) {
      await client.query("ROLLBACK");

      if (error?.code === "23505") {
        throw new ApiError(409, "Un modèle portant ce nom existe déjà.");
      }

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

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

    if (!templateId) {
      throw new ApiError(400, "Identifiant de modèle invalide.");
    }

    const client = await pool.connect();

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

      const { rows: existingRows } = await client.query(
        `
          SELECT id, name, journal_id, lines
          FROM entry_templates
          WHERE id = $1
            AND company_id = $2
          LIMIT 1
        `,
        [templateId, companyId],
      );

      const existingTemplate = existingRows[0] || null;

      if (!existingTemplate) {
        throw new ApiError(404, "Modèle introuvable.");
      }

      const name = cleanString(req.body.name || existingTemplate.name);
      const journalId = Number(req.body.journal_id || existingTemplate.journal_id || 0);

      if (!name) {
        throw new ApiError(400, "Le nom du modèle est obligatoire.");
      }

      if (!journalId) {
        throw new ApiError(400, "Le journal du modèle est obligatoire.");
      }

      const journal = await fetchJournalById(client, companyId, journalId);

      if (!journal) {
        throw new ApiError(400, "Le journal du modèle est introuvable.");
      }

      const normalized = await normalizeTemplateLines({
        db: client,
        companyId,
        rawLines: req.body.lines,
      });

      await client.query(
        `
          UPDATE entry_templates
          SET
            name = $2,
            journal_id = $3,
            lines = $4::jsonb
          WHERE id = $1
            AND company_id = $5
        `,
        [templateId, name, journalId, JSON.stringify(normalized.lines), companyId],
      );

      const savedTemplate = {
        id: templateId,
        name,
        journal_id: journalId,
        journal_code: journal.code,
        lines: normalized.lines,
      };

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entry_templates.update",
        entityType: "entry_template",
        entityId: templateId,
        oldData: sanitizeTemplateForAudit({
          id: Number(existingTemplate.id),
          name: existingTemplate.name,
          journal_id: Number(existingTemplate.journal_id),
          lines: existingTemplate.lines || [],
        }),
        newData: sanitizeTemplateForAudit({
          ...savedTemplate,
          lines_count: savedTemplate.lines.length,
        }),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.json({
        message: "Modèle mis à jour.",
        item: {
          ...savedTemplate,
          journal_label: journal.label,
          journal_type: journal.journal_type,
          journal_type_label: JOURNAL_TYPE_LABELS[journal.journal_type] || journal.journal_type,
          lines_count: savedTemplate.lines.length,
        },
      });
    } catch (error) {
      await client.query("ROLLBACK");

      if (error?.code === "23505") {
        throw new ApiError(409, "Un modèle portant ce nom existe déjà.");
      }

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

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

    if (!templateId) {
      throw new ApiError(400, "Identifiant de modèle invalide.");
    }

    const client = await pool.connect();

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

      const { rows } = await client.query(
        `
          SELECT id, name, journal_id, lines
          FROM entry_templates
          WHERE id = $1
            AND company_id = $2
          LIMIT 1
        `,
        [templateId, companyId],
      );

      const existingTemplate = rows[0] || null;

      if (!existingTemplate) {
        throw new ApiError(404, "Modèle introuvable.");
      }

      await client.query(
        `
          DELETE FROM entry_templates
          WHERE id = $1
            AND company_id = $2
        `,
        [templateId, companyId],
      );

      await logAudit(client, {
        companyId,
        userId: req.user?.sub || null,
        action: "entry_templates.delete",
        entityType: "entry_template",
        entityId: templateId,
        oldData: sanitizeTemplateForAudit({
          id: Number(existingTemplate.id),
          name: existingTemplate.name,
          journal_id: Number(existingTemplate.journal_id),
          lines: existingTemplate.lines || [],
        }),
        ipAddress: req.ip,
      });

      await client.query("COMMIT");

      res.status(204).send();
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  },
};
