import { readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";

import { PDFDocument } from "pdf-lib";

const PAYSLIP_FILE_PATTERN =
  /^(\d{4})_(0[1-9]|1[0-2])-Paies ([^\\/]+)\.pdf$/i;
const MATRICULE_LABEL_PATTERN = /\bmatricule\b\s*:?/i;
const MAX_INDEX_CACHE_ENTRIES = 50;

export type PayslipMonthOption = {
  month: number;
};

export type PayslipYearOption = {
  year: number;
  months: PayslipMonthOption[];
};

export type PayslipEstablishmentOption = {
  establishment: string;
  years: PayslipYearOption[];
};

export type ParsedPayslipFileName = {
  year: number;
  month: number;
  label: string;
};

type MatriculeMarker = {
  hasMarker: boolean;
  matricule: string | null;
};

export type PdfTextItemLike = {
  str: string;
  transform?: readonly number[];
  width?: number;
  height?: number;
};

type PdfIndex = {
  signature: string;
  rangesByMatricule: Map<string, number[][]>;
  markerCount: number;
  nonEmptyPageCount: number;
};

export class PayslipError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly status: number,
  ) {
    super(message);
    this.name = "PayslipError";
  }
}

const pdfIndexCache = new Map<string, PdfIndex>();

function normalizeText(value: string) {
  return value
    .normalize("NFKC")
    .replace(/[\u00a0\u202f]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function normalizeComparable(value: string) {
  return normalizeText(value)
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLocaleUpperCase("fr");
}

export function normalizeMatricule(value: string | number) {
  const digits = String(value).trim();

  if (!/^\d+$/.test(digits)) return null;

  return digits.replace(/^0+(?=\d)/, "");
}

export function parsePayslipFileName(
  fileName: string,
): ParsedPayslipFileName | null {
  const match = PAYSLIP_FILE_PATTERN.exec(fileName);

  if (!match) return null;

  return {
    year: Number(match[1]),
    month: Number(match[2]),
    label: match[3].trim(),
  };
}

export function extractMatriculeMarker(text: string): MatriculeMarker {
  const normalized = normalizeText(text);
  const label = MATRICULE_LABEL_PATTERN.exec(normalized);

  if (!label || label.index === undefined) {
    return { hasMarker: false, matricule: null };
  }

  const textAfterLabel = normalized.slice(label.index + label[0].length);
  const numberMatch = /^\s*[:#-]?\s*(\d{1,20})\b/.exec(textAfterLabel);

  return {
    hasMarker: true,
    matricule: numberMatch ? normalizeMatricule(numberMatch[1]) : null,
  };
}

function extractNumberAtStart(value: string) {
  const match = /^\s*[:#-]?\s*(\d{1,20})\b/.exec(normalizeText(value));
  return match ? normalizeMatricule(match[1]) : null;
}

export function extractMatriculeMarkerFromItems(
  items: readonly PdfTextItemLike[],
): MatriculeMarker {
  const pageText = items.map((item) => item.str).join(" ");
  const directMarker = extractMatriculeMarker(pageText);

  if (!directMarker.hasMarker || directMarker.matricule) {
    return directMarker;
  }

  const labelItems = items.filter((item) =>
    /\bmatricule\b/i.test(normalizeText(item.str)),
  );
  let bestCandidate: { matricule: string; score: number } | null = null;

  for (const labelItem of labelItems) {
    const labelTransform = labelItem.transform;

    if (!labelTransform || labelTransform.length < 6) continue;

    const labelX = labelTransform[4];
    const labelY = labelTransform[5];
    const labelRight = labelX + Math.max(labelItem.width ?? 0, 0);
    const labelHeight = Math.max(labelItem.height ?? 0, 4);

    for (const candidateItem of items) {
      if (candidateItem === labelItem) continue;

      const matricule = extractNumberAtStart(candidateItem.str);
      const candidateTransform = candidateItem.transform;

      if (
        !matricule ||
        !candidateTransform ||
        candidateTransform.length < 6
      ) {
        continue;
      }

      const candidateX = candidateTransform[4];
      const candidateY = candidateTransform[5];
      const deltaX = candidateX - labelRight;
      const deltaY = Math.abs(candidateY - labelY);
      const candidateHeight = Math.max(candidateItem.height ?? 0, 4);
      const sameLineTolerance =
        Math.min(Math.max(labelHeight, candidateHeight), 12) * 0.8 + 2;

      if (deltaX < -5 || deltaX > 250 || deltaY > sameLineTolerance) {
        continue;
      }

      const score = deltaY * 100 + Math.max(deltaX, 0);

      if (!bestCandidate || score < bestCandidate.score) {
        bestCandidate = { matricule, score };
      }
    }
  }

  return {
    hasMarker: true,
    matricule: bestCandidate?.matricule ?? null,
  };
}

function getPayslipRootPath() {
  return path.resolve(process.env.PAYSLIP_ROOT_PATH ?? "/data/paies");
}

function isEstablishmentAllowed(
  establishment: string,
  allowedEstablishments: string[] | undefined,
) {
  if (allowedEstablishments === undefined) return true;

  const normalized = normalizeComparable(establishment);
  return allowedEstablishments.some(
    (allowed) => normalizeComparable(allowed) === normalized,
  );
}

async function readDirectoryOrThrow(directoryPath: string) {
  try {
    return await readdir(directoryPath, { withFileTypes: true });
  } catch {
    throw new PayslipError(
      "PAYSLIP_STORAGE_UNAVAILABLE",
      "Le dossier des fiches de paie est indisponible.",
      503,
    );
  }
}

export async function listAvailablePayslips(
  allowedEstablishments: string[] | undefined,
): Promise<PayslipEstablishmentOption[]> {
  const rootPath = getPayslipRootPath();
  const establishmentEntries = await readDirectoryOrThrow(rootPath);
  const result: PayslipEstablishmentOption[] = [];

  for (const establishmentEntry of establishmentEntries) {
    if (!establishmentEntry.isDirectory()) continue;
    if (
      !isEstablishmentAllowed(
        establishmentEntry.name,
        allowedEstablishments,
      )
    ) {
      continue;
    }

    const establishmentPath = path.join(rootPath, establishmentEntry.name);
    const yearEntries = await readDirectoryOrThrow(establishmentPath);
    const years: PayslipYearOption[] = [];

    for (const yearEntry of yearEntries) {
      if (!yearEntry.isDirectory() || !/^\d{4}$/.test(yearEntry.name)) {
        continue;
      }

      const year = Number(yearEntry.name);
      const yearPath = path.join(establishmentPath, yearEntry.name);
      const fileEntries = await readDirectoryOrThrow(yearPath);
      const months = new Set<number>();

      for (const fileEntry of fileEntries) {
        if (!fileEntry.isFile()) continue;

        const parsed = parsePayslipFileName(fileEntry.name);

        if (parsed?.year === year) {
          months.add(parsed.month);
        }
      }

      if (months.size > 0) {
        years.push({
          year,
          months: [...months]
            .sort((left, right) => right - left)
            .map((month) => ({ month })),
        });
      }
    }

    if (years.length > 0) {
      result.push({
        establishment: establishmentEntry.name,
        years: years.sort((left, right) => right.year - left.year),
      });
    }
  }

  return result.sort((left, right) =>
    left.establishment.localeCompare(right.establishment, "fr"),
  );
}

async function resolveMonthlyPayslipPath(
  establishment: string,
  year: number,
  month: number,
  allowedEstablishments: string[] | undefined,
) {
  if (!isEstablishmentAllowed(establishment, allowedEstablishments)) {
    throw new PayslipError(
      "ESTABLISHMENT_FORBIDDEN",
      "Vous n'êtes pas autorisé à consulter cet établissement.",
      403,
    );
  }

  const rootPath = getPayslipRootPath();
  const establishmentEntries = await readDirectoryOrThrow(rootPath);
  const matchingEstablishments = establishmentEntries.filter(
    (entry) =>
      entry.isDirectory() &&
      normalizeComparable(entry.name) === normalizeComparable(establishment),
  );

  if (matchingEstablishments.length === 0) {
    throw new PayslipError(
      "ESTABLISHMENT_NOT_FOUND",
      "L'établissement demandé n'existe pas dans le dossier des paies.",
      404,
    );
  }

  if (matchingEstablishments.length > 1) {
    throw new PayslipError(
      "ESTABLISHMENT_AMBIGUOUS",
      "Plusieurs dossiers correspondent à cet établissement.",
      409,
    );
  }

  const establishmentPath = path.join(
    rootPath,
    matchingEstablishments[0].name,
  );
  const yearEntries = await readDirectoryOrThrow(establishmentPath);
  const yearEntry = yearEntries.find(
    (entry) => entry.isDirectory() && entry.name === String(year),
  );

  if (!yearEntry) {
    throw new PayslipError(
      "PAYSLIP_YEAR_NOT_FOUND",
      "Aucun dossier de paie n'existe pour cette année.",
      404,
    );
  }

  const yearPath = path.join(establishmentPath, yearEntry.name);
  const fileEntries = await readDirectoryOrThrow(yearPath);
  const matchingFiles = fileEntries.filter((entry) => {
    if (!entry.isFile()) return false;

    const parsed = parsePayslipFileName(entry.name);
    return parsed?.year === year && parsed.month === month;
  });

  if (matchingFiles.length === 0) {
    throw new PayslipError(
      "PAYSLIP_FILE_NOT_FOUND",
      "Aucun fichier de paie n'existe pour cette période.",
      404,
    );
  }

  if (matchingFiles.length > 1) {
    throw new PayslipError(
      "PAYSLIP_FILE_AMBIGUOUS",
      "Plusieurs fichiers de paie correspondent à cette période.",
      409,
    );
  }

  return path.join(yearPath, matchingFiles[0].name);
}

function addRange(
  rangesByMatricule: Map<string, number[][]>,
  matricule: string | null,
  pages: number[],
) {
  if (!matricule || pages.length === 0) return;

  const existing = rangesByMatricule.get(matricule) ?? [];
  existing.push(pages);
  rangesByMatricule.set(matricule, existing);
}

async function buildPdfIndex(
  pdfBytes: Uint8Array,
  signature: string,
): Promise<PdfIndex> {
  const { getDocument } = await import(
    "pdfjs-dist/legacy/build/pdf.mjs"
  );
  const loadingTask = getDocument({
    data: Uint8Array.from(pdfBytes),
    useSystemFonts: true,
  });
  const pdf = await loadingTask.promise;
  const rangesByMatricule = new Map<string, number[][]>();
  let currentMatricule: string | null = null;
  let currentPages: number[] = [];
  let markerCount = 0;
  let nonEmptyPageCount = 0;

  try {
    for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
      const page = await pdf.getPage(pageNumber);
      const textContent = await page.getTextContent();
      const textItems = textContent.items
        .filter((item) => "str" in item)
        .map((item) => ({
          str: item.str,
          transform: item.transform,
          width: item.width,
          height: item.height,
        }));
      const pageText = textItems.map((item) => item.str).join(" ");

      if (normalizeText(pageText)) nonEmptyPageCount += 1;

      const marker = extractMatriculeMarkerFromItems(textItems);
      const pageIndex = pageNumber - 1;

      if (marker.hasMarker) {
        markerCount += 1;
        addRange(rangesByMatricule, currentMatricule, currentPages);
        currentMatricule = marker.matricule;
        currentPages = [pageIndex];
      } else if (currentPages.length > 0) {
        currentPages.push(pageIndex);
      }

      page.cleanup();
    }

    addRange(rangesByMatricule, currentMatricule, currentPages);
  } finally {
    await loadingTask.destroy();
  }

  return {
    signature,
    rangesByMatricule,
    markerCount,
    nonEmptyPageCount,
  };
}

function cachePdfIndex(filePath: string, index: PdfIndex) {
  if (pdfIndexCache.size >= MAX_INDEX_CACHE_ENTRIES) {
    const oldestKey = pdfIndexCache.keys().next().value as string | undefined;
    if (oldestKey) pdfIndexCache.delete(oldestKey);
  }

  pdfIndexCache.set(filePath, index);
}

export async function extractEmployeePayslip(input: {
  establishment: string;
  year: number;
  month: number;
  matricule: number;
  allowedEstablishments: string[] | undefined;
}) {
  const normalizedMatricule = normalizeMatricule(input.matricule);

  if (!normalizedMatricule) {
    throw new PayslipError(
      "EMPLOYEE_MATRICULE_INVALID",
      "Le matricule du salarié est invalide.",
      422,
    );
  }

  const filePath = await resolveMonthlyPayslipPath(
    input.establishment,
    input.year,
    input.month,
    input.allowedEstablishments,
  );
  const fileStat = await stat(filePath);
  const signature = `${fileStat.size}:${fileStat.mtimeMs}`;
  const pdfBytes = Uint8Array.from(await readFile(filePath));
  let index = pdfIndexCache.get(filePath);

  if (!index || index.signature !== signature) {
    index = await buildPdfIndex(pdfBytes, signature);
    cachePdfIndex(filePath, index);
  }

  if (index.markerCount === 0) {
    const reason =
      index.nonEmptyPageCount === 0
        ? "Le PDF ne contient pas de texte extractible."
        : "Aucun repère Matricule n'a été détecté dans le PDF.";

    throw new PayslipError(
      "PAYSLIP_TEXT_NOT_USABLE",
      `${reason} Une vérification du format ou un OCR est nécessaire.`,
      422,
    );
  }

  const ranges = index.rangesByMatricule.get(normalizedMatricule) ?? [];

  if (ranges.length === 0) {
    throw new PayslipError(
      "EMPLOYEE_PAYSLIP_NOT_FOUND",
      "Aucune fiche correspondant au matricule de ce salarié n'a été trouvée.",
      404,
    );
  }

  const pageIndices = ranges.flat();

  const sourcePdf = await PDFDocument.load(pdfBytes);
  const outputPdf = await PDFDocument.create();
  const copiedPages = await outputPdf.copyPages(sourcePdf, pageIndices);

  for (const copiedPage of copiedPages) {
    outputPdf.addPage(copiedPage);
  }

  const result = await outputPdf.save({ useObjectStreams: true });

  return {
    bytes: Uint8Array.from(result),
    pageCount: pageIndices.length,
  };
}
