import { createHash, randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";

import Docxtemplater from "docxtemplater";
import PizZip from "pizzip";

import { prisma } from "@/lib/prisma";

const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 50 * 1024 * 1024;
const MAX_BATCH_FILE_BYTES = 100 * 1024 * 1024;
const MAX_BATCH_EXPANDED_BYTES = 250 * 1024 * 1024;
const MAX_BATCH_ITEMS = 100;
const execFileAsync = promisify(execFile);

export class DocumentTemplateError extends Error {
  constructor(
    public readonly code:
      | "INVALID_FILE"
      | "MACRO_FORBIDDEN"
      | "NO_PLACEHOLDER"
      | "CODE_EXISTS"
      | "TEMPLATE_NOT_FOUND"
      | "VERSION_NOT_FOUND",
  ) {
    super(code);
    this.name = "DocumentTemplateError";
  }
}

export class DocumentTemplateBatchError extends Error {
  constructor(
    public readonly code:
      | "INVALID_ARCHIVE"
      | "MANIFEST_REQUIRED"
      | "INVALID_MANIFEST"
      | "TOO_MANY_TEMPLATES",
  ) {
    super(code);
    this.name = "DocumentTemplateBatchError";
  }
}

type BatchManifestEntry = {
  fichier: string;
  libelle: string;
  code: string;
  categorie: string;
  etablissement: string;
  description: string;
};

export type DocumentTemplateBatchResult = {
  fichier: string;
  code: string;
  libelle: string;
  status: "imported" | "skipped" | "failed";
  message?: string;
  templateId?: number;
};

export type TemplateField = {
  key: string;
  label: string;
  type: "text" | "date" | "number" | "boolean";
  source: "manual" | "employee" | "contract" | "signatory" | "establishment";
  sourcePath: string | null;
  required: boolean;
};

function storageRoot() {
  return process.env.DOCUMENT_TEMPLATES_ROOT || "/data/document-templates";
}

function fieldLabel(key: string) {
  return key
    .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
    .replace(/[_.-]+/g, " ")
    .replace(/^./, (letter) => letter.toUpperCase());
}

function inspectDocx(buffer: Buffer): TemplateField[] {
  if (
    buffer.length === 0 ||
    buffer.length > MAX_FILE_BYTES ||
    buffer[0] !== 0x50 ||
    buffer[1] !== 0x4b
  ) {
    throw new DocumentTemplateError("INVALID_FILE");
  }

  let zip: PizZip;
  try {
    zip = new PizZip(buffer);
  } catch {
    throw new DocumentTemplateError("INVALID_FILE");
  }

  const names = Object.keys(zip.files);
  if (!names.includes("[Content_Types].xml") || !names.includes("word/document.xml")) {
    throw new DocumentTemplateError("INVALID_FILE");
  }

  if (names.some((name) => name.toLowerCase().includes("vbaproject"))) {
    throw new DocumentTemplateError("MACRO_FORBIDDEN");
  }

  let expandedBytes = 0;
  for (const name of names) {
    const entry = zip.files[name];
    if (entry.dir) continue;
    expandedBytes += entry.asUint8Array().byteLength;
    if (expandedBytes > MAX_EXPANDED_BYTES) {
      throw new DocumentTemplateError("INVALID_FILE");
    }
  }

  let text: string;
  try {
    const template = new Docxtemplater(zip, {
      paragraphLoop: true,
      linebreaks: true,
      nullGetter: () => "",
    });
    text = template.getFullText();
  } catch {
    throw new DocumentTemplateError("INVALID_FILE");
  }

  const keys = new Set<string>();
  for (const match of text.matchAll(/\{([A-Za-z][A-Za-z0-9_.-]{0,99})\}/g)) {
    keys.add(match[1]);
  }

  if (keys.size === 0) {
    throw new DocumentTemplateError("NO_PLACEHOLDER");
  }

  return [...keys].sort().map((key) => ({
    key,
    label: fieldLabel(key),
    type: key.toLowerCase().startsWith("date") ? "date" : "text",
    source: "manual",
    sourcePath: null,
    required: true,
  }));
}

function normalizeArchivePath(value: string) {
  const normalized = value.trim().replace(/\\/g, "/").replace(/^\.\//, "");
  if (
    !normalized ||
    normalized.startsWith("/") ||
    normalized.split("/").some((part) => part === "..")
  ) {
    throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  }
  return normalized;
}

function parseDelimitedLine(line: string, delimiter: string) {
  const values: string[] = [];
  let value = "";
  let quoted = false;
  for (let index = 0; index < line.length; index += 1) {
    const character = line[index];
    if (character === '"') {
      if (quoted && line[index + 1] === '"') {
        value += '"';
        index += 1;
      } else {
        quoted = !quoted;
      }
    } else if (character === delimiter && !quoted) {
      values.push(value.trim());
      value = "";
    } else {
      value += character;
    }
  }
  if (quoted) throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  values.push(value.trim());
  return values;
}

function normalizeManifestEntry(value: unknown): BatchManifestEntry {
  if (!value || typeof value !== "object") {
    throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  }
  const record = value as Record<string, unknown>;
  const entry = {
    fichier: normalizeArchivePath(String(record.fichier ?? "")),
    libelle: String(record.libelle ?? "").trim(),
    code: String(record.code ?? "").trim(),
    categorie: String(record.categorie ?? "").trim(),
    etablissement: String(record.etablissement ?? "").trim(),
    description: String(record.description ?? "").trim(),
  };
  if (
    !entry.fichier.toLowerCase().endsWith(".docx") ||
    entry.libelle.length < 2 || entry.libelle.length > 255 ||
    !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.code) || entry.code.length > 100 ||
    entry.categorie.length < 2 || entry.categorie.length > 100 ||
    entry.etablissement.length > 150 || entry.description.length > 5000
  ) {
    throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  }
  return entry;
}

function parseCsvManifest(content: string) {
  const lines = content.replace(/^\uFEFF/, "").split(/\r?\n/).filter((line) => line.trim());
  if (lines.length < 2) throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  const delimiter = lines[0].includes(";") ? ";" : ",";
  const headers = parseDelimitedLine(lines[0], delimiter).map((header) => header.toLowerCase());
  for (const required of ["fichier", "libelle", "code", "categorie"]) {
    if (!headers.includes(required)) throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  }
  return lines.slice(1).map((line) => {
    const columns = parseDelimitedLine(line, delimiter);
    if (columns.length !== headers.length) {
      throw new DocumentTemplateBatchError("INVALID_MANIFEST");
    }
    return normalizeManifestEntry(
      Object.fromEntries(headers.map((header, index) => [header, columns[index]])),
    );
  });
}

function parseJsonManifest(content: string) {
  let parsed: unknown;
  try {
    parsed = JSON.parse(content.replace(/^\uFEFF/, ""));
  } catch {
    throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  }
  const items = Array.isArray(parsed)
    ? parsed
    : parsed && typeof parsed === "object" && Array.isArray((parsed as { templates?: unknown }).templates)
      ? (parsed as { templates: unknown[] }).templates
      : null;
  if (!items) throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  return items.map(normalizeManifestEntry);
}

function findArchiveDocument(names: string[], requestedPath: string) {
  const requested = normalizeArchivePath(requestedPath).toLowerCase();
  const suffixes = [`/${requested}`, `/templates/${requested}`];
  const matches = names.filter((name) => {
    const normalized = normalizeArchivePath(name).toLowerCase();
    return normalized === requested || suffixes.some((suffix) => normalized.endsWith(suffix));
  });
  return matches.length === 1 ? matches[0] : null;
}

export async function importDocumentTemplateBatch(file: File) {
  if (!file.name.toLowerCase().endsWith(".zip") || file.size > MAX_BATCH_FILE_BYTES) {
    throw new DocumentTemplateBatchError("INVALID_ARCHIVE");
  }

  let archive: PizZip;
  try {
    archive = new PizZip(Buffer.from(await file.arrayBuffer()));
  } catch {
    throw new DocumentTemplateBatchError("INVALID_ARCHIVE");
  }

  const fileNames = Object.keys(archive.files).filter((name) => !archive.files[name].dir);
  let expandedBytes = 0;
  for (const name of fileNames) {
    expandedBytes += archive.files[name].asUint8Array().byteLength;
    if (expandedBytes > MAX_BATCH_EXPANDED_BYTES) {
      throw new DocumentTemplateBatchError("INVALID_ARCHIVE");
    }
  }

  const manifests = fileNames
    .filter((name) => /(^|\/)manifest\.(csv|json)$/i.test(name))
    .sort((left, right) => left.split("/").length - right.split("/").length);
  if (manifests.length === 0) throw new DocumentTemplateBatchError("MANIFEST_REQUIRED");
  const manifestName = manifests[0];
  const manifestContent = archive.files[manifestName].asText();
  const entries = manifestName.toLowerCase().endsWith(".json")
    ? parseJsonManifest(manifestContent)
    : parseCsvManifest(manifestContent);
  if (entries.length === 0) throw new DocumentTemplateBatchError("INVALID_MANIFEST");
  if (entries.length > MAX_BATCH_ITEMS) throw new DocumentTemplateBatchError("TOO_MANY_TEMPLATES");

  const existingCodes = new Set(
    (await prisma.modeles_documents.findMany({
      where: { code: { in: entries.map((entry) => entry.code) } },
      select: { code: true },
    })).map((item: { code: string }) => item.code),
  );
  const encounteredCodes = new Set<string>();
  const results: DocumentTemplateBatchResult[] = [];

  for (const entry of entries) {
    if (existingCodes.has(entry.code)) {
      results.push({ ...entry, status: "skipped", message: "CODE_EXISTS" });
      continue;
    }
    if (encounteredCodes.has(entry.code)) {
      results.push({ ...entry, status: "skipped", message: "DUPLICATE_IN_MANIFEST" });
      continue;
    }
    encounteredCodes.add(entry.code);

    const archiveName = findArchiveDocument(fileNames, entry.fichier);
    if (!archiveName) {
      results.push({ ...entry, status: "failed", message: "FILE_NOT_FOUND_OR_AMBIGUOUS" });
      continue;
    }

    try {
      const bytes = archive.files[archiveName].asUint8Array();
      const docxBytes = Uint8Array.from(bytes).buffer;
      const docx = new File([docxBytes], path.basename(entry.fichier), {
        type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
      });
      const imported = await importDocumentTemplate({
        code: entry.code,
        libelle: entry.libelle,
        description: entry.description,
        categorie: entry.categorie,
        etablissement: entry.etablissement,
        file: docx,
      });
      existingCodes.add(entry.code);
      results.push({
        ...entry,
        status: "imported",
        templateId: imported.template.id,
      });
    } catch (error) {
      results.push({
        ...entry,
        status: "failed",
        message: error instanceof DocumentTemplateError ? error.code : "IMPORT_FAILED",
      });
    }
  }

  return {
    results,
    summary: {
      total: results.length,
      imported: results.filter((item) => item.status === "imported").length,
      skipped: results.filter((item) => item.status === "skipped").length,
      failed: results.filter((item) => item.status === "failed").length,
    },
  };
}

export async function listDocumentTemplates() {
  return prisma.modeles_documents.findMany({
    orderBy: [{ categorie: "asc" }, { libelle: "asc" }],
    include: {
      versions: {
        orderBy: { numero: "desc" },
        select: {
          id: true,
          numero: true,
          nom_fichier_original: true,
          taille_octets: true,
          sha256: true,
          champs: true,
          statut: true,
          date_creation: true,
        },
      },
    },
  });
}

export async function importDocumentTemplate(input: {
  templateId?: number;
  code: string;
  libelle: string;
  description: string;
  categorie: string;
  etablissement: string;
  file: File;
}) {
  if (!input.file.name.toLowerCase().endsWith(".docx")) {
    throw new DocumentTemplateError("INVALID_FILE");
  }

  const buffer = Buffer.from(await input.file.arrayBuffer());
  const fields = inspectDocx(buffer);
  const sha256 = createHash("sha256").update(buffer).digest("hex");

  const existing = input.templateId
    ? await prisma.modeles_documents.findUnique({ where: { id: input.templateId } })
    : null;
  if (input.templateId && !existing) {
    throw new DocumentTemplateError("TEMPLATE_NOT_FOUND");
  }
  if (existing && existing.code !== input.code) {
    throw new DocumentTemplateError("INVALID_FILE");
  }
  if (!existing) {
    const duplicate = await prisma.modeles_documents.findUnique({
      where: { code: input.code },
      select: { id: true },
    });
    if (duplicate) throw new DocumentTemplateError("CODE_EXISTS");
  }

  const latest = existing
    ? await prisma.versions_modeles_documents.findFirst({
        where: { modele_id: existing.id },
        orderBy: { numero: "desc" },
        select: { numero: true },
      })
    : null;
  const version = (latest?.numero ?? 0) + 1;
  const storageName = `${input.code}/v${version}/${randomUUID()}.docx`;
  const absolutePath = path.join(storageRoot(), storageName);
  await mkdir(path.dirname(absolutePath), { recursive: true });
  await writeFile(absolutePath, buffer, { flag: "wx" });

  try {
    return await prisma.$transaction(async (tx) => {
      const template = existing
        ? await tx.modeles_documents.update({
            where: { id: existing.id },
            data: {
              libelle: input.libelle,
              description: input.description || null,
              categorie: input.categorie,
              etablissement: input.etablissement || null,
            },
          })
        : await tx.modeles_documents.create({
            data: {
              code: input.code,
              libelle: input.libelle,
              description: input.description || null,
              categorie: input.categorie,
              etablissement: input.etablissement || null,
            },
          });

      const createdVersion = await tx.versions_modeles_documents.create({
        data: {
          modele_id: template.id,
          numero: version,
          nom_fichier_original: input.file.name,
          nom_fichier_stockage: storageName,
          taille_octets: buffer.length,
          sha256,
          champs: fields,
        },
      });

      return { template, version: createdVersion, fields };
    });
  } catch (error) {
    await rm(absolutePath, { force: true }).catch(() => undefined);
    throw error;
  }
}

export async function activateDocumentTemplate(input: {
  templateId: number;
  version: number;
  actif: boolean;
}) {
  const version = await prisma.versions_modeles_documents.findFirst({
    where: { modele_id: input.templateId, numero: input.version },
  });
  if (!version) throw new DocumentTemplateError("VERSION_NOT_FOUND");

  return prisma.$transaction(async (tx) => {
    await tx.versions_modeles_documents.updateMany({
      where: { modele_id: input.templateId },
      data: { statut: "ARCHIVE" },
    });
    await tx.versions_modeles_documents.update({
      where: { id: version.id },
      data: { statut: input.actif ? "ACTIVE" : "BROUILLON" },
    });
    return tx.modeles_documents.update({
      where: { id: input.templateId },
      data: {
        actif: input.actif,
        version_active: input.actif ? input.version : null,
      },
    });
  });
}

function sampleValue(field: TemplateField) {
  if (field.type === "date") return "19/08/2026";
  if (field.type === "number") return "123";
  if (field.type === "boolean") return "Oui";
  return `[Exemple ${field.label}]`;
}

export async function previewDocumentTemplate(templateId: number, versionNumber: number) {
  const version = await prisma.versions_modeles_documents.findFirst({
    where: { modele_id: templateId, numero: versionNumber },
  });
  if (!version) throw new DocumentTemplateError("VERSION_NOT_FOUND");

  const source = await readFile(path.join(storageRoot(), version.nom_fichier_stockage));
  const fields = version.champs as unknown as TemplateField[];
  const values = Object.fromEntries(fields.map((field) => [field.key, sampleValue(field)]));
  const zip = new PizZip(source);
  const document = new Docxtemplater(zip, {
    paragraphLoop: true,
    linebreaks: true,
    nullGetter: () => "",
  });
  document.render(values);
  const docx = document.getZip().generate({
    type: "nodebuffer",
    compression: "DEFLATE",
  }) as Buffer;

  const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "rh-connect-doc-preview-"));
  const docxPath = path.join(temporaryDirectory, "preview.docx");
  const pdfPath = path.join(temporaryDirectory, "preview.pdf");

  try {
    await writeFile(docxPath, docx);
    await execFileAsync(
      "soffice",
      [
        "--headless",
        "--nologo",
        "--nodefault",
        "--nolockcheck",
        "--convert-to",
        "pdf",
        "--outdir",
        temporaryDirectory,
        docxPath,
      ],
      {
        timeout: 60_000,
        env: {
          ...process.env,
          HOME: temporaryDirectory,
          SAL_USE_VCLPLUGIN: "svp",
        },
      },
    );
    const pdf = await readFile(pdfPath);
    await prisma.versions_modeles_documents.update({
      where: { id: version.id },
      data: { statut: version.statut === "ACTIVE" ? "ACTIVE" : "APERCU_GENERE" },
    });
    return pdf;
  } finally {
    await rm(temporaryDirectory, { recursive: true, force: true });
  }
}
