import { mkdir, readFile, rm, writeFile } from "fs/promises";
import { join, basename } from "path";
import { randomUUID } from "crypto";
import { prisma } from "@/lib/prisma/prisma";
import { extractDataFromInvoice } from "@/genkit/flow";
import { documentService, DuplicateDocumentError } from "@/lib/services/documentService";
import { Prisma, ScannerFileStatus, ScannerSessionStatus } from "@/generated/prisma/client";
import { PDFDocument } from "pdf-lib";

const VALID_EXTENSIONS = new Set(["pdf", "jpg", "jpeg", "png"]);

export type SharePointFile = { id: string; name: string; mimeType: string };

type GraphToken = { accessToken: string; expiresAt: number };

/**
 * Fuente SharePoint para el nuevo scanner. Es deliberadamente independiente
 * del flujo legacy de /upload y de sus carpetas locales de procesamiento.
 */
export class SharePointScannerService {
  private token: GraphToken | null = null;

  isEnabled(): boolean {
    return process.env.SHAREPOINT_ENABLED === "true" || process.env.SHAREPOINT_ENABLED === "1";
  }

  /** `delete` es el comportamiento predeterminado solicitado para el scanner. */
  afterDownloadAction(): "delete" | "keep" {
    return process.env.SHAREPOINT_AFTER_DOWNLOAD?.trim().toLowerCase() === "keep" ? "keep" : "delete";
  }

  folders(): Array<{ label: string; path: string }> {
    const base = this.normalizeFolder(process.env.SHAREPOINT_FOLDER_PATH ?? "");
    const configured = (process.env.OCR_SCANNER_FOLDERS ?? "").trim();
    if (!configured) return base ? [{ label: "Carpeta principal", path: base }] : [];

    const folders = new Map<string, { label: string; path: string }>();
    for (const rawEntry of configured.split(",")) {
      const entry = rawEntry.trim();
      if (!entry) continue;
      const [rawLabel, rawPath] = entry.includes("=")
        ? entry.split("=", 2).map((value) => value.trim())
        : [entry, entry.includes("/") ? entry : `${base}/${entry}`];
      const path = this.normalizeFolder(rawPath);
      if (rawLabel && path) folders.set(path.toLocaleLowerCase(), { label: rawLabel, path });
    }
    return [...folders.values()];
  }

  async listFiles(folderPath: string): Promise<SharePointFile[]> {
    const driveId = await this.resolveDriveId();
    const encodedPath = this.normalizeFolder(folderPath).split("/").map(encodeURIComponent).join("/");
    if (!encodedPath) throw new Error("La carpeta de SharePoint no está configurada.");

    let url: string | undefined = `https://graph.microsoft.com/v1.0/drives/${driveId}/root:/${encodedPath}:/children`;
    const files: SharePointFile[] = [];
    while (url) {
      const response = await this.graphFetch(url);
      const data = await response.json() as { value?: Array<{ id: string; name: string; folder?: unknown; file?: { mimeType?: string } }>; "@odata.nextLink"?: string };
      for (const item of data.value ?? []) {
        if (item.folder) continue;
        const extension = item.name.split(".").pop()?.toLowerCase() ?? "";
        if (VALID_EXTENSIONS.has(extension)) {
          files.push({ id: item.id, name: item.name, mimeType: item.file?.mimeType || this.mimeType(item.name) });
        }
      }
      url = data["@odata.nextLink"];
    }
    return files;
  }

  async createSession(input: { userId: string; categoryId: string; folderPath: string; customFields: string; confirmClear: boolean }) {
    this.assertAllowedFolder(input.folderPath);
    const activeSession = await this.activeSession(input.userId, "USER");
    if (activeSession) throw new Error("Ya tienes una sesión de escáner abierta. Ciérrala antes de iniciar otra.");
    const normalizedFolder = this.normalizeFolder(input.folderPath);
    const folderInUse = await this.activeSessionForFolder(normalizedFolder);
    if (folderInUse) {
      throw new Error("La carpeta/scanner seleccionada está siendo utilizada por otra sesión activa. Espera a que se cierre antes de abrirla.");
    }
    const pending = await this.listFiles(normalizedFolder);
    if (pending.length > 0 && !input.confirmClear) return { confirmationRequired: true, pending } as const;

    let session;
    try {
      session = await prisma.$transaction(async (tx) => {
        const created = await tx.scannerSession.create({
          data: {
            userId: input.userId,
            categoryId: input.categoryId,
            folderPath: normalizedFolder,
            customFields: input.customFields.trim(),
            events: { create: [{ userId: input.userId, type: "session_started", message: "Sesión SharePoint iniciada." }] },
          },
          include: { category: { select: { id: true, name: true, icon: true, color: true } } },
        });
        await tx.scannerSessionLock.createMany({
          data: [
            { lockKey: `scanner:user:${input.userId}`, sessionId: created.id },
            { lockKey: `scanner:folder:${normalizedFolder.toLowerCase()}`, sessionId: created.id },
          ],
        });
        return created;
      });
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
        throw new Error("No se pudo abrir la sesión porque el usuario o la carpeta/scanner ya están en uso por otra sesión activa.");
      }
      throw error;
    }

    const deleted: string[] = [];
    try {
      // Consulta nuevamente tras adquirir las llaves para limpiar exactamente la carpeta bloqueada.
      for (const file of await this.listFiles(normalizedFolder)) {
        await this.deleteRemoteFile(file.id);
        deleted.push(file.name);
      }
      if (deleted.length) {
        await prisma.scannerSessionEvent.create({ data: { sessionId: session.id, userId: input.userId, type: "pre_session_clear", message: `Se eliminaron ${deleted.length} archivo(s) existentes antes de iniciar la sesión.`, context: { files: deleted } } });
      }
    } catch (error) {
      const message = error instanceof Error ? error.message : "No se pudo limpiar la carpeta de SharePoint.";
      await prisma.scannerSession.update({ where: { id: session.id }, data: { status: "FAILED", finishedAt: new Date() } });
      await prisma.scannerSessionEvent.create({ data: { sessionId: session.id, userId: input.userId, type: "pre_session_clear_failed", message } });
      throw new Error(`La sesión quedó bloqueada porque no se pudo limpiar la carpeta: ${message}`);
    }
    return { confirmationRequired: false, session, deleted } as const;
  }

  async getSession(sessionId: string, userId: string, role: "USER" | "ADMIN") {
    const session = await prisma.scannerSession.findUnique({
      where: { id: sessionId },
      include: { category: { select: { id: true, name: true, icon: true, color: true } }, files: { orderBy: { createdAt: "desc" } }, events: { orderBy: { createdAt: "desc" }, take: 20 } },
    });
    if (!session) throw new Error("Sesión no encontrada.");
    if (role !== "ADMIN" && session.userId !== userId) throw new Error("No tienes permisos para acceder a esta sesión.");
    return session;
  }

  async activeSession(userId: string, role: "USER" | "ADMIN") {
    return prisma.scannerSession.findFirst({
      where: {
        ...(role === "ADMIN" ? {} : { userId }),
        status: { in: ["ACTIVE", "PROCESSING", "FAILED"] },
      },
      include: {
        category: { select: { id: true, name: true, icon: true, color: true } },
        files: { orderBy: { createdAt: "desc" } },
        events: { orderBy: { createdAt: "desc" }, take: 20 },
      },
      orderBy: { startedAt: "desc" },
    });
  }

  async activeSessions(userId: string, role: "USER" | "ADMIN") {
    return prisma.scannerSession.findMany({
      where: {
        ...(role === "ADMIN" ? {} : { userId }),
        status: { in: ["ACTIVE", "PROCESSING", "FAILED"] },
      },
      include: {
        user: { select: { id: true, name: true, username: true } },
        category: { select: { id: true, name: true, icon: true, color: true } },
        files: { orderBy: { createdAt: "desc" } },
        events: { orderBy: { createdAt: "desc" }, take: 20 },
      },
      orderBy: { startedAt: "desc" },
    });
  }

  /** Carpetas bloqueadas por cualquier sesión activa, para informar al selector. */
  async activeFolderPaths() {
    const sessions = await prisma.scannerSession.findMany({
      where: { status: { in: ["ACTIVE", "PROCESSING", "FAILED"] } },
      select: { folderPath: true },
    });
    return [...new Set(sessions.map((session) => session.folderPath))];
  }

  /** Evita que dos usuarios procesen simultáneamente la misma carpeta/scanner. */
  private async activeSessionForFolder(folderPath: string) {
    return prisma.scannerSession.findFirst({
      where: {
        folderPath,
        status: { in: ["ACTIVE", "PROCESSING", "FAILED"] },
      },
      select: { id: true },
      orderBy: { startedAt: "desc" },
    });
  }

  async history(userId: string, role: "USER" | "ADMIN") {
    return prisma.scannerSession.findMany({
      where: role === "ADMIN" ? undefined : { userId },
      include: {
        user: { select: { name: true, username: true } },
        category: { select: { id: true, name: true, icon: true, color: true } },
        files: { orderBy: { createdAt: "asc" } },
        events: { orderBy: { createdAt: "desc" }, take: 50 },
      },
      orderBy: { startedAt: "desc" },
      take: 100,
    });
  }

  async closeSession(sessionId: string, userId: string, role: "USER" | "ADMIN") {
    const session = await this.getSession(sessionId, userId, role);
    if (session.status === ScannerSessionStatus.PROCESSING) {
      throw new Error("No puedes cerrar una sesión mientras se está procesando.");
    }
    await prisma.scannerSession.update({
      where: { id: session.id },
      data: { status: ScannerSessionStatus.COMPLETED, finishedAt: new Date() },
    });
    await prisma.scannerSessionEvent.create({
      data: { sessionId: session.id, userId, type: "session_closed", message: "Sesión cerrada manualmente por el usuario." },
    });
    await prisma.scannerSessionLock.deleteMany({ where: { sessionId: session.id } });
  }

  async processSession(sessionId: string, userId: string, role: "USER" | "ADMIN") {
    const session = await this.getSession(sessionId, userId, role);
    if (session.status === ScannerSessionStatus.COMPLETED) throw new Error("La sesión ya fue cerrada.");

    await prisma.scannerSession.update({ where: { id: sessionId }, data: { status: ScannerSessionStatus.PROCESSING } });
    await this.recordProgress(sessionId, userId, "processing_started", "Buscando documentos en SharePoint…", { phase: "searching" });
    let files: SharePointFile[];
    try {
      files = await this.listFiles(session.folderPath);
    } catch (error) {
      const message = error instanceof Error ? error.message : "No se pudieron buscar los documentos en SharePoint.";
      await prisma.scannerSession.update({ where: { id: sessionId }, data: { status: ScannerSessionStatus.FAILED, finishedAt: new Date() } });
      await this.recordProgress(sessionId, userId, "processing_failed", `No se pudieron buscar los documentos: ${message}`, { phase: "failed" });
      throw error;
    }
    await this.recordProgress(sessionId, userId, "files_found", files.length ? `Se encontraron ${files.length} documento(s).` : "No se encontraron documentos para procesar.", {
      phase: "files_found",
      totalFiles: files.length,
      processedFiles: 0,
    });
    const result: Array<{ name: string; sourceItemId: string; status: "extracted" | "failed"; error?: string }> = [];

    for (const [fileIndex, file] of files.entries()) {
      const currentFileIndex = fileIndex + 1;
      try {
        await this.recordProgress(sessionId, userId, "file_downloading", `Buscando y descargando ${file.name}…`, {
          phase: "downloading",
          currentFile: file.name,
          currentFileIndex,
          totalFiles: files.length,
          processedFiles: fileIndex,
        });
        const buffer = await this.downloadFile(file.id);
        const pages = await this.splitIntoPages(file, buffer);
        await this.recordProgress(sessionId, userId, "file_processing", `Procesando ${file.name} (${pages.length} página(s))…`, {
          phase: "processing",
          currentFile: file.name,
          currentFileIndex,
          totalFiles: files.length,
          processedFiles: fileIndex,
          currentPage: 0,
          totalPages: pages.length,
        });
        let sourceHasFailures = false;

        for (const [pageIndex, page] of pages.entries()) {
          try {
            await this.recordProgress(sessionId, userId, "page_processing", `Procesando ${file.name}, página ${pageIndex + 1} de ${pages.length}…`, {
              phase: "processing",
              currentFile: file.name,
              currentFileIndex,
              totalFiles: files.length,
              processedFiles: fileIndex,
              currentPage: pageIndex + 1,
              totalPages: pages.length,
            });
            const extracted = await extractDataFromInvoice({
              imageParts: [{ data: page.buffer.toString("base64"), mimeType: file.mimeType }],
              customFields: session.customFields,
            });
            const stagedFileName = await this.stageFile(session.id, page.fileName, page.buffer);
            await prisma.scannerSessionFile.upsert({
              where: { sessionId_sharepointItemId_pageNumber: { sessionId: session.id, sharepointItemId: file.id, pageNumber: page.pageNumber } },
              create: { sessionId: session.id, sharepointItemId: file.id, pageNumber: page.pageNumber, fileName: page.fileName, mimeType: file.mimeType, stagedFileName, extractedData: extracted.extractedData as Prisma.InputJsonValue },
              update: { fileName: page.fileName, mimeType: file.mimeType, stagedFileName, extractedData: extracted.extractedData as Prisma.InputJsonValue, status: "EXTRACTED", errorMessage: null, remoteDeletedAt: null },
            });
            result.push({ name: page.fileName, sourceItemId: file.id, status: "extracted" });
          } catch (error) {
            sourceHasFailures = true;
            const message = error instanceof Error ? error.message : "Error desconocido al procesar la página.";
            result.push({ name: page.fileName, sourceItemId: file.id, status: "failed", error: message });
            // Un error al guardar el log no debe volver a propagar el fallo ni
            // contar dos veces el mismo archivo/página.
            try {
              await prisma.scannerSessionFile.upsert({
                where: { sessionId_sharepointItemId_pageNumber: { sessionId: session.id, sharepointItemId: file.id, pageNumber: page.pageNumber } },
                create: { sessionId: session.id, sharepointItemId: file.id, pageNumber: page.pageNumber, fileName: page.fileName, mimeType: file.mimeType, stagedFileName: "", status: "FAILED", errorMessage: message },
                update: { status: "FAILED", errorMessage: message },
              });
              await prisma.scannerSessionEvent.create({ data: { sessionId: session.id, userId, type: "page_failed", message: `No se procesó ${page.fileName}: ${message}`, context: { sharepointItemId: file.id, pageNumber: page.pageNumber } } });
            } catch (persistenceError) {
              console.error("No se pudo registrar el fallo de la página del escáner:", persistenceError);
            }
          }
        }

        // Un PDF remoto solo se elimina cuando todas sus páginas fueron extraídas.
        if (!sourceHasFailures && this.afterDownloadAction() === "delete") {
          await this.deleteRemoteFile(file.id);
          await prisma.scannerSessionFile.updateMany({ where: { sessionId: session.id, sharepointItemId: file.id }, data: { remoteDeletedAt: new Date() } });
        }
        await this.recordProgress(sessionId, userId, "file_completed", `${file.name} terminado.`, {
          phase: "file_completed",
          currentFile: file.name,
          currentFileIndex,
          totalFiles: files.length,
          processedFiles: currentFileIndex,
        });
      } catch (error) {
        const message = error instanceof Error ? error.message : "Error desconocido al procesar el archivo.";
        await prisma.scannerSessionEvent.create({ data: { sessionId: session.id, userId, type: "file_failed", message: `No se procesó ${file.name}: ${message}`, context: { sharepointItemId: file.id, phase: "file_failed", currentFile: file.name, currentFileIndex, totalFiles: files.length, processedFiles: currentFileIndex } } });
        result.push({ name: file.name, sourceItemId: file.id, status: "failed", error: message });
      }
    }

    const failedPages = result.filter((item) => item.status === "failed").length;
    const failedSourceFiles = new Set(result.filter((item) => item.status === "failed").map((item) => item.sourceItemId)).size;
    const extractedPages = result.filter((item) => item.status === "extracted").length;
    await prisma.scannerSession.update({ where: { id: session.id }, data: { status: failedPages ? ScannerSessionStatus.FAILED : ScannerSessionStatus.ACTIVE, finishedAt: failedPages ? new Date() : null } });
    await prisma.scannerSessionEvent.create({ data: { sessionId: session.id, userId, type: "processing_finished", message: `Procesamiento terminado: ${extractedPages} página(s) extraída(s), ${failedPages} página(s) fallida(s) en ${failedSourceFiles} archivo(s) origen.`, context: { result, afterDownload: this.afterDownloadAction(), phase: "finished", totalFiles: files.length, processedFiles: files.length } } });
    return { result, summary: { extractedPages, failedPages, failedSourceFiles }, session: await this.getSession(session.id, userId, role) };
  }

  async availableFiles(sessionId: string, userId: string, role: "USER" | "ADMIN") {
    const session = await this.getSession(sessionId, userId, role);
    if (session.status === ScannerSessionStatus.PROCESSING) return [];

    const remoteFiles = await this.listFiles(session.folderPath);
    // Cuando SHAREPOINT_AFTER_DOWNLOAD=keep, los archivos ya extraídos siguen
    // en remoto. Solo se notifican de nuevo los que son nuevos o fallaron.
    const sourceStatuses = new Map<string, ScannerFileStatus[]>();
    for (const file of session.files) {
      const statuses = sourceStatuses.get(file.sharepointItemId) ?? [];
      statuses.push(file.status);
      sourceStatuses.set(file.sharepointItemId, statuses);
    }
    return remoteFiles.filter((file) => {
      const statuses = sourceStatuses.get(file.id);
      return !statuses || statuses.some((status) => status === "FAILED");
    });
  }

  private async recordProgress(sessionId: string, userId: string, type: string, message: string, context: Record<string, unknown>) {
    await prisma.scannerSessionEvent.create({ data: { sessionId, userId, type, message, context: context as Prisma.InputJsonValue } });
  }

  async saveFile(sessionId: string, fileId: string, extractedData: Record<string, unknown>, userId: string, role: "USER" | "ADMIN") {
    const session = await this.getSession(sessionId, userId, role);
    const file = session.files.find((item) => item.id === fileId);
    if (!file) throw new Error("Archivo de sesión no encontrado.");
    if (file.status === "SAVED") return { documentId: file.documentId };
    const buffer = await readFile(this.stagedPath(sessionId, file.stagedFileName));
    let document;
    try {
      document = await documentService.createDocumentFromBuffer({ fileName: file.fileName, buffer, extractedData, ownerId: session.userId, categoryId: session.categoryId, duplicateSource: "SHAREPOINT" });
    } catch (error) {
      // Persistimos el motivo para que la bandeja pueda identificar la duplicada
      // incluso si el usuario recarga la página antes de descartarla.
      if (error instanceof DuplicateDocumentError) {
        await prisma.scannerSessionFile.update({ where: { id: file.id }, data: { errorMessage: error.message } });
      }
      throw error;
    }
    await prisma.scannerSessionFile.update({ where: { id: file.id }, data: { status: "SAVED", documentId: document.id, extractedData: extractedData as Prisma.InputJsonValue } });
    await rm(this.stagedPath(sessionId, file.stagedFileName), { force: true });
    return { documentId: document.id };
  }

  /**
   * Descarta únicamente resultados aún no guardados. Los documentos que el
   * usuario ya guardó pertenecen al repositorio local y no se eliminan.
   */
  async discardUnsavedFiles(sessionId: string, userId: string, role: "USER" | "ADMIN") {
    const session = await this.getSession(sessionId, userId, role);
    if (session.status === ScannerSessionStatus.PROCESSING) {
      throw new Error("No puedes cancelar resultados mientras se está procesando.");
    }

    return this.discardFiles(session, session.files.filter((file) => file.status !== "SAVED"), userId, role, "results_discarded");
  }

  /** Descarta únicamente resultados que fueron rechazados por ser duplicados. */
  async discardDuplicateFiles(sessionId: string, userId: string, role: "USER" | "ADMIN") {
    const session = await this.getSession(sessionId, userId, role);
    if (session.status === ScannerSessionStatus.PROCESSING) {
      throw new Error("No puedes cancelar resultados mientras se está procesando.");
    }

    const filesToDiscard = session.files.filter((file) => file.status !== "SAVED" && file.errorMessage?.startsWith("Esta factura ya fue guardada"));
    return this.discardFiles(session, filesToDiscard, userId, role, "duplicate_results_discarded");
  }

  private async discardFiles(session: Awaited<ReturnType<SharePointScannerService["getSession"]>>, filesToDiscard: Array<{ id: string; stagedFileName: string; status: ScannerFileStatus }>, userId: string, role: "USER" | "ADMIN", eventType: string) {
    await Promise.all(filesToDiscard.map((file) =>
      file.stagedFileName ? rm(this.stagedPath(session.id, file.stagedFileName), { force: true }) : Promise.resolve(),
    ));
    if (filesToDiscard.length) {
      await prisma.scannerSessionFile.deleteMany({
        where: { id: { in: filesToDiscard.map((file) => file.id) } },
      });
      await prisma.scannerSessionEvent.create({
        data: {
          sessionId: session.id,
          userId,
          type: eventType,
          message: eventType === "duplicate_results_discarded"
            ? `Se descartaron ${filesToDiscard.length} resultado(s) duplicado(s).`
            : `Se descartaron ${filesToDiscard.length} resultado(s) sin guardar.`,
        },
      });
    }
    return { discardedCount: filesToDiscard.length, session: await this.getSession(session.id, userId, role) };
  }

  private normalizeFolder(path: string) { return path.replace(/\\/g, "/").replace(/\/{2,}/g, "/").trim().replace(/^\/+|\/+$/g, ""); }
  private assertAllowedFolder(folderPath: string) {
    if (!this.isEnabled()) throw new Error("La integración con SharePoint está deshabilitada.");
    const normalized = this.normalizeFolder(folderPath).toLowerCase();
    if (!this.folders().some((folder) => folder.path.toLowerCase() === normalized)) throw new Error("La carpeta seleccionada no está permitida.");
  }
  private mimeType(name: string) { return ({ pdf: "application/pdf", jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png" } as Record<string, string>)[name.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream"; }
  private async splitIntoPages(file: SharePointFile, buffer: Buffer): Promise<Array<{ buffer: Buffer; fileName: string; pageNumber: number }>> {
    if (file.mimeType !== "application/pdf") return [{ buffer, fileName: file.name, pageNumber: 1 }];
    const source = await PDFDocument.load(buffer);
    if (source.getPageCount() <= 1) return [{ buffer, fileName: file.name, pageNumber: 1 }];
    const baseName = file.name.replace(/\.pdf$/i, "");
    return Promise.all(source.getPageIndices().map(async (pageIndex) => {
      const pageDocument = await PDFDocument.create();
      const [page] = await pageDocument.copyPages(source, [pageIndex]);
      pageDocument.addPage(page);
      return { buffer: Buffer.from(await pageDocument.save()), fileName: `${baseName} - página ${pageIndex + 1}.pdf`, pageNumber: pageIndex + 1 };
    }));
  }
  private async stageFile(sessionId: string, fileName: string, buffer: Buffer) {
    const stagedFileName = `${randomUUID()}-${basename(fileName).replace(/[^a-zA-Z0-9._-]/g, "_")}`;
    await mkdir(join(process.cwd(), "uploads", "scanner", sessionId), { recursive: true });
    await writeFile(this.stagedPath(sessionId, stagedFileName), buffer);
    return stagedFileName;
  }
  private stagedPath(sessionId: string, stagedFileName: string) { return join(process.cwd(), "uploads", "scanner", sessionId, basename(stagedFileName)); }
  private async downloadFile(itemId: string) { const driveId = await this.resolveDriveId(); const response = await this.graphFetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`); return Buffer.from(await response.arrayBuffer()); }
  private async deleteRemoteFile(itemId: string) { const driveId = await this.resolveDriveId(); await this.graphFetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}`, { method: "DELETE" }); }
  private async resolveDriveId(): Promise<string> {
    const siteUrl = (process.env.SHAREPOINT_SITE_URL ?? "").replace(/\/$/, "");
    if (!siteUrl) throw new Error("SHAREPOINT_SITE_URL no está configurado.");
    if (/-my\.sharepoint\.com\/personal\//i.test(siteUrl)) {
      const user = process.env.SHAREPOINT_ONEDRIVE_USER;
      if (!user) throw new Error("SHAREPOINT_ONEDRIVE_USER es obligatorio para OneDrive personal.");
      return this.driveIdFrom(`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(user)}/drive`);
    }
    const parsed = new URL(siteUrl);
    const siteResponse = await this.graphFetch(`https://graph.microsoft.com/v1.0/sites/${parsed.host}:/${parsed.pathname.replace(/^\//, "")}`);
    const site = await siteResponse.json() as { id?: string };
    if (!site.id) throw new Error("No se pudo resolver el sitio de SharePoint.");
    return this.driveIdFrom(`https://graph.microsoft.com/v1.0/sites/${site.id}/drive`);
  }
  private async driveIdFrom(url: string) { const response = await this.graphFetch(url); const drive = await response.json() as { id?: string }; if (!drive.id) throw new Error("No se pudo resolver el drive de SharePoint."); return drive.id; }
  private async graphFetch(url: string, init: RequestInit = {}) {
    const response = await fetch(url, { ...init, headers: { Authorization: `Bearer ${await this.accessToken()}`, Accept: "application/json", ...init.headers } });
    if (!response.ok) throw new Error(`SharePoint respondió ${response.status}: ${await response.text()}`);
    return response;
  }
  private async accessToken() {
    if (this.token && this.token.expiresAt > Date.now() + 60_000) return this.token.accessToken;
    const tenant = process.env.SHAREPOINT_TENANT_ID, clientId = process.env.SHAREPOINT_CLIENT_ID, secret = process.env.SHAREPOINT_CLIENT_SECRET;
    if (!tenant || !clientId || !secret) throw new Error("Faltan credenciales de SharePoint.");
    const response = await fetch(`https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: secret, scope: "https://graph.microsoft.com/.default" }) });
    if (!response.ok) throw new Error("No se pudo obtener el token de SharePoint.");
    const token = await response.json() as { access_token?: string; expires_in?: number };
    if (!token.access_token) throw new Error("SharePoint no devolvió un token válido.");
    this.token = { accessToken: token.access_token, expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000 };
    return this.token.accessToken;
  }
}

export const sharePointScannerService = new SharePointScannerService();
