/**
 * Copyright (c) 2023-present Plane Software, Inc. and contributors
 * SPDX-License-Identifier: AGPL-3.0-only
 * See the LICENSE file for details.
 */

// hocuspocus
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import type { AnyExtension, JSONContent } from "@tiptap/core";
import type * as Y from "yjs";
// editor extensions
import {
  TITLE_EDITOR_EXTENSIONS,
  createRealtimeEvent,
  extractTextFromHTML,
  generateTitleProsemirrorJson,
} from "@plane/editor";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// helpers
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
import { broadcastMessageToPage } from "@/utils/broadcast-message";
import { TitleUpdateManager } from "./title-update/title-update-manager";

/**
 * Hocuspocus extension for synchronizing document titles
 */
export class TitleSyncExtension implements Extension {
  // Maps document names to their observers and update managers
  private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
  private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
  // Store minimal data needed for each document's title observer (prevents closure memory leaks)
  private titleObserverData: Map<
    string,
    {
      parentId?: string | null;
      userId: string;
      workspaceSlug: string | null;
      instance: Hocuspocus;
    }
  > = new Map();

  /**
   * Handle document loading - migrate old titles if needed
   */
  async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
    try {
      // initially for on demand migration of old titles to a new title field
      // in the yjs binary
      if (document.isEmpty("title")) {
        const service = getPageService(context.documentType, context);
        const pageDetails = await service.fetchDetails(documentName);
        const title = pageDetails.name;
        if (title == null) return;
        const titleJson = (generateTitleProsemirrorJson as (text: string) => JSONContent)(title);
        const titleField = TiptapTransformer.toYdoc(titleJson, "title", TITLE_EDITOR_EXTENSIONS as AnyExtension[]);
        document.merge(titleField);
      }
    } catch (error) {
      const appError = new AppError(error, {
        context: { operation: "onLoadDocument", documentName },
      });
      logger.error("Error loading document title", appError);
    }
  }
  /**
   * Set up title synchronization for a document after it's loaded
   */
  async afterLoadDocument({
    document,
    documentName,
    context,
    instance,
  }: {
    document: Document;
    documentName: string;
    context: HocusPocusServerContext;
    instance: Hocuspocus;
  }) {
    // Create a title update manager for this document
    const updateManager = new TitleUpdateManager(documentName, context);

    // Store the manager
    this.titleUpdateManagers.set(documentName, updateManager);

    // Store minimal data needed for the observer (prevents closure memory leak)
    this.titleObserverData.set(documentName, {
      userId: context.userId,
      workspaceSlug: context.workspaceSlug,
      instance: instance,
    });

    // Create observer using bound method to avoid closure capturing heavy objects
    const titleObserver = this.handleTitleChange.bind(this, documentName);

    // Observe the title field
    document.getXmlFragment("title").observeDeep(titleObserver);
    this.titleObservers.set(documentName, titleObserver);
  }

  /**
   * Handle title changes for a document
   * This is a separate method to avoid closure memory leaks
   */
  private handleTitleChange(documentName: string, events: Y.YEvent<any>[]) {
    let title = "";
    events.forEach((event) => {
      title = extractTextFromHTML(event.currentTarget.toJSON() as string);
    });

    // Get the manager for this document
    const manager = this.titleUpdateManagers.get(documentName);

    // Get the stored data for this document
    const data = this.titleObserverData.get(documentName);

    // Broadcast to parent page if it exists
    if (data?.parentId && data.workspaceSlug && data.instance) {
      const event = createRealtimeEvent({
        user_id: data.userId,
        workspace_slug: data.workspaceSlug,
        action: "property_updated",
        page_id: documentName,
        data: { name: title },
        descendants_ids: [],
      });

      // Use the instance from stored data (guaranteed to be set)
      broadcastMessageToPage(data.instance, data.parentId, event);
    }

    // Schedule the title update
    if (manager) {
      manager.scheduleUpdate(title);
    }
  }

  /**
   * Force save title before unloading the document
   */
  async beforeUnloadDocument({ documentName }: { documentName: string }) {
    const updateManager = this.titleUpdateManagers.get(documentName);
    if (updateManager) {
      // Force immediate save and wait for it to complete
      await updateManager.forceSave();
      // Clean up the manager
      this.titleUpdateManagers.delete(documentName);
    }
  }

  /**
   * Remove observers after document unload
   */
  async afterUnloadDocument({ documentName, document }: { documentName: string; document?: Document }) {
    // Clean up observer when document is unloaded
    const observer = this.titleObservers.get(documentName);
    if (observer) {
      // unregister observer from Y.js document to prevent memory leak
      if (document) {
        try {
          document.getXmlFragment("title").unobserveDeep(observer);
        } catch (error) {
          logger.error("Failed to unobserve title field", new AppError(error, { context: { documentName } }));
        }
      }
      this.titleObservers.delete(documentName);
    }

    // Clean up the observer data map to prevent memory leak
    this.titleObserverData.delete(documentName);

    // Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
    if (this.titleUpdateManagers.has(documentName)) {
      const manager = this.titleUpdateManagers.get(documentName)!;
      manager.cancel();
      this.titleUpdateManagers.delete(documentName);
    }
  }
}
