import {
  Injectable,
  Logger,
  ForbiddenException,
  NotFoundException,
} from '@nestjs/common';
import { isDeepStrictEqual } from 'node:util';
import { v7 as uuid7 } from 'uuid';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
import { PageTemplateReferencesRepo } from '@docmost/db/repos/page-template-references/page-template-references.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { StorageService } from '../../../integrations/storage/storage.service';
import {
  collectPageEmbedsFromPmJson,
  collectReferencesFromPmJson,
  collectTransclusionsFromPmJson,
} from './utils/transclusion-prosemirror.util';
import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util';
import {
  PageTemplateLookup,
  TransclusionLookup,
} from './transclusion.types';
import {
  getProsemirrorContent,
  removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils';
import { jsonToNode } from '../../../collaboration/collaboration.util';
import { Page, User } from '@docmost/db/types/entity.types';
import { PageAccessService } from '../page-access/page-access.service';

type ReferencingPageInfo = {
  id: string;
  slugId: string;
  title: string | null;
  icon: string | null;
  spaceId: string;
  spaceSlug: string | null;
};

@Injectable()
export class TransclusionService {
  private readonly logger = new Logger(TransclusionService.name);

  constructor(
    @InjectKysely() private readonly db: KyselyDB,
    private readonly pageTransclusionsRepo: PageTransclusionsRepo,
    private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
    private readonly pageTemplateReferencesRepo: PageTemplateReferencesRepo,
    private readonly pageRepo: PageRepo,
    private readonly pagePermissionRepo: PagePermissionRepo,
    private readonly spaceMemberRepo: SpaceMemberRepo,
    private readonly attachmentRepo: AttachmentRepo,
    private readonly storageService: StorageService,
    private readonly pageAccessService: PageAccessService,
  ) {}

  async syncPageTransclusions(
    pageId: string,
    workspaceId: string,
    pmJson: unknown,
    trx?: KyselyTransaction,
  ): Promise<{ inserted: number; updated: number; deleted: number }> {
    const desired = collectTransclusionsFromPmJson(pmJson);
    const desiredById = new Map(desired.map((d) => [d.transclusionId, d]));

    const existing = await this.pageTransclusionsRepo.findByPageId(pageId, trx);
    const existingById = new Map(existing.map((e) => [e.transclusionId, e]));

    let inserted = 0;
    let updated = 0;
    let deleted = 0;

    for (const d of desired) {
      const prev = existingById.get(d.transclusionId);
      if (!prev) {
        await this.pageTransclusionsRepo.insert(
          {
            workspaceId,
            pageId,
            transclusionId: d.transclusionId,
            content: d.content as any,
          },
          trx,
        );
        inserted += 1;
        continue;
      }

      const contentChanged = !isDeepStrictEqual(prev.content, d.content);
      if (contentChanged) {
        await this.pageTransclusionsRepo.update(
          pageId,
          d.transclusionId,
          { content: d.content as any },
          trx,
        );
        updated += 1;
      }
    }

    const removedIds = existing
      .filter((e) => !desiredById.has(e.transclusionId))
      .map((e) => e.transclusionId);
    if (removedIds.length > 0) {
      await this.pageTransclusionsRepo.deleteByPageAndTransclusionIds(
        pageId,
        removedIds,
        trx,
      );
      deleted = removedIds.length;
    }

    return { inserted, updated, deleted };
  }

  async syncPageReferences(
    referencePageId: string,
    workspaceId: string,
    pmJson: unknown,
    trx?: KyselyTransaction,
  ): Promise<{ inserted: number; deleted: number }> {
    const desired = collectReferencesFromPmJson(pmJson);
    const keyOf = (s: {
      sourcePageId: string;
      transclusionId: string;
    }) => `${s.sourcePageId}::${s.transclusionId}`;
    const desiredKeys = new Set(desired.map(keyOf));

    const existing = await this.pageTransclusionReferencesRepo.findByReferencePageId(
      referencePageId,
      trx,
    );
    const existingKeys = new Set(existing.map(keyOf));

    const toInsert = desired
      .filter((d) => !existingKeys.has(keyOf(d)))
      .map((d) => ({
        workspaceId,
        referencePageId,
        sourcePageId: d.sourcePageId,
        transclusionId: d.transclusionId,
      }));

    const toDelete = existing
      .filter((e) => !desiredKeys.has(keyOf(e)))
      .map((e) => ({
        sourcePageId: e.sourcePageId,
        transclusionId: e.transclusionId,
      }));

    if (toInsert.length > 0) {
      await this.pageTransclusionReferencesRepo.insertMany(toInsert, trx);
    }
    if (toDelete.length > 0) {
      await this.pageTransclusionReferencesRepo.deleteByReferenceAndKeys(
        referencePageId,
        toDelete,
        trx,
      );
    }

    return {
      inserted: toInsert.length,
      deleted: toDelete.length,
    };
  }

  /**
   * Extract transclusions from each page's PM JSON and bulk-insert into
   * `page_transclusions` in a single statement. Intended for brand-new pages
   * (e.g. duplication, import) where there is nothing to diff against.
   */
  async insertTransclusionsForPages(
    pages: Array<{ id: string; workspaceId: string; content: unknown }>,
    trx?: KyselyTransaction,
  ): Promise<{ inserted: number }> {
    const rows: Parameters<PageTransclusionsRepo['insertMany']>[0] = [];
    for (const page of pages) {
      const snapshots = collectTransclusionsFromPmJson(page.content);
      for (const s of snapshots) {
        rows.push({
          workspaceId: page.workspaceId,
          pageId: page.id,
          transclusionId: s.transclusionId,
          content: s.content as any,
        });
      }
    }
    if (rows.length === 0) return { inserted: 0 };
    await this.pageTransclusionsRepo.insertMany(rows, trx);
    return { inserted: rows.length };
  }

  /**
   * Walk each page's PM JSON for `transclusionReference` nodes and bulk-insert
   * one row per `(referencePage, source, target)`. For brand-new pages
   * (duplication, import) where there is nothing to diff against.
   */
  async insertReferencesForPages(
    pages: Array<{ id: string; workspaceId: string; content: unknown }>,
    trx?: KyselyTransaction,
  ): Promise<{ inserted: number }> {
    const rows: Array<{
      workspaceId: string;
      referencePageId: string;
      sourcePageId: string;
      transclusionId: string;
    }> = [];
    for (const page of pages) {
      const refs = collectReferencesFromPmJson(page.content);
      for (const r of refs) {
        rows.push({
          workspaceId: page.workspaceId,
          referencePageId: page.id,
          sourcePageId: r.sourcePageId,
          transclusionId: r.transclusionId,
        });
      }
    }
    if (rows.length === 0) return { inserted: 0 };
    await this.pageTransclusionReferencesRepo.insertMany(rows, trx);
    return { inserted: rows.length };
  }

  // ---------------------------------------------------------------------------
  // Whole-page live embeds (pageEmbed node)
  // ---------------------------------------------------------------------------

  /**
   * Restrict a set of candidate `pageEmbed` source ids to the pages that
   * actually live in `workspaceId` (and are not soft-deleted). Defense in depth:
   * `page_template_references` is NOT access-filtered, so we must never persist a
   * reference to a cross-workspace source page. This is a single workspace-scoped
   * existence query; it does NOT do per-viewer permission filtering (that stays
   * the job of `lookupTemplate` at read time — see the warning below).
   */
  private async filterInWorkspaceSourceIds(
    sourceIds: string[],
    workspaceId: string,
    trx?: KyselyTransaction,
  ): Promise<Set<string>> {
    if (sourceIds.length === 0) return new Set();
    const db = trx ?? this.db;
    const rows = await db
      .selectFrom('pages')
      .select('id')
      .where('id', 'in', sourceIds)
      .where('workspaceId', '=', workspaceId)
      .where('deletedAt', 'is', null)
      .execute();
    return new Set(rows.map((r) => r.id));
  }

  /**
   * Diff `page_template_references` for a host page against the `pageEmbed`
   * nodes currently in its content. Mirror of `syncPageReferences` but keyed by
   * `sourcePageId` only (whole-page, no transclusionId). Idempotent.
   *
   * SECURITY: `page_template_references` rows are NOT access-filtered. Inserts
   * are restricted here to in-workspace source pages so the graph can never
   * accumulate cross-workspace edges, but rows are still NOT per-viewer
   * permission-filtered. EVERY consumer of these rows MUST permission-filter at
   * read time (as `lookupTemplate` does via `filterViewerAccessiblePageIds`).
   *
   * NOTE (write-only graph — intentional, not dead): as of now the
   * `page_template_references` table is WRITE-ONLY in production. It is populated
   * by three paths (this diff-sync, `insertTemplateReferencesForPages` for new
   * pages, and the collab persistence flush) but has NO production reader: the
   * only read of the table is `findByReferencePageId` below, used purely to
   * compute this sync's insert/delete diff — there is no reverse-navigation
   * consumer yet (issue #34's dead `findReferencePageIdsBySource` reader was
   * already removed). The graph is retained deliberately for an upcoming
   * "used in N pages" reverse-navigation consumer; keep writing it so that
   * feature has correct history when it lands. Do not remove the write graph or
   * its migration just because nothing reads it today. (See Gitea #94.)
   */
  async syncPageTemplateReferences(
    referencePageId: string,
    workspaceId: string,
    pmJson: unknown,
    trx?: KyselyTransaction,
  ): Promise<{ inserted: number; deleted: number }> {
    const desired = collectPageEmbedsFromPmJson(pmJson);
    const inWorkspace = await this.filterInWorkspaceSourceIds(
      desired.map((d) => d.sourcePageId),
      workspaceId,
      trx,
    );
    const desiredIds = new Set(
      desired.map((d) => d.sourcePageId).filter((id) => inWorkspace.has(id)),
    );

    const existing =
      await this.pageTemplateReferencesRepo.findByReferencePageId(
        referencePageId,
        trx,
      );
    const existingIds = new Set(existing.map((e) => e.sourcePageId));

    const toInsert = Array.from(desiredIds)
      .filter((id) => !existingIds.has(id))
      .map((sourcePageId) => ({
        workspaceId,
        referencePageId,
        sourcePageId,
      }));

    const toDelete = existing
      .filter((e) => !desiredIds.has(e.sourcePageId))
      .map((e) => e.sourcePageId);

    if (toInsert.length > 0) {
      await this.pageTemplateReferencesRepo.insertMany(toInsert, trx);
    }
    if (toDelete.length > 0) {
      await this.pageTemplateReferencesRepo.deleteByReferenceAndSources(
        referencePageId,
        workspaceId,
        toDelete,
        trx,
      );
    }

    return { inserted: toInsert.length, deleted: toDelete.length };
  }

  /**
   * Bulk-insert `page_template_references` for brand-new pages (duplication,
   * import) where there is nothing to diff against.
   *
   * SECURITY: like `syncPageTemplateReferences`, inserts are restricted to
   * in-workspace source pages so the (non-access-filtered) reference graph never
   * gains a cross-workspace edge. Read-time per-viewer permission filtering is
   * still required by every consumer.
   */
  async insertTemplateReferencesForPages(
    pages: Array<{ id: string; workspaceId: string; content: unknown }>,
    trx?: KyselyTransaction,
  ): Promise<{ inserted: number }> {
    // Collect candidate source ids per workspace, then validate each workspace's
    // set in a single existence query before building insert rows.
    const candidatesByWorkspace = new Map<string, Set<string>>();
    const pageEmbeds = pages.map((page) => {
      const sourceIds = collectPageEmbedsFromPmJson(page.content).map(
        (e) => e.sourcePageId,
      );
      let set = candidatesByWorkspace.get(page.workspaceId);
      if (!set) {
        set = new Set();
        candidatesByWorkspace.set(page.workspaceId, set);
      }
      for (const id of sourceIds) set.add(id);
      return { page, sourceIds };
    });

    const inWorkspaceByWorkspace = new Map<string, Set<string>>();
    for (const [workspaceId, candidates] of candidatesByWorkspace) {
      inWorkspaceByWorkspace.set(
        workspaceId,
        await this.filterInWorkspaceSourceIds(
          Array.from(candidates),
          workspaceId,
          trx,
        ),
      );
    }

    const rows: Array<{
      workspaceId: string;
      referencePageId: string;
      sourcePageId: string;
    }> = [];
    for (const { page, sourceIds } of pageEmbeds) {
      const inWorkspace = inWorkspaceByWorkspace.get(page.workspaceId);
      for (const sourcePageId of sourceIds) {
        if (!inWorkspace?.has(sourcePageId)) continue;
        rows.push({
          workspaceId: page.workspaceId,
          referencePageId: page.id,
          sourcePageId,
        });
      }
    }
    if (rows.length === 0) return { inserted: 0 };
    await this.pageTemplateReferencesRepo.insertMany(rows, trx);
    return { inserted: rows.length };
  }

  /**
   * Resolve whole-page content for a set of source page ids on behalf of an
   * authenticated viewer. For each accessible page returns its current content
   * with `comment` marks stripped (comments belong to the source). Inaccessible
   * pages return `no_access`, missing/deleted pages return `not_found`. Does NOT
   * require `is_template` — any accessible page can be embedded (the template
   * flag only affects picker discovery).
   *
   * FLAT, single-level by design: this returns each requested page's own content
   * verbatim and never recurses. If a returned page itself contains a `pageEmbed`
   * node pointing at another page, that embed is left unresolved — the client
   * issues a follow-up lookup for it. Because there is no server-side recursive
   * expansion, there is no server depth/cycle to guard here: the embed depth/cycle
   * cap (PAGE_EMBED_MAX_DEPTH) is purely a client RENDER concern. A scripted client
   * that walks the graph manually is bounded by the per-user throttle (30/60s) on
   * the controller plus the DTO's ArrayMaxSize(50) per call.
   */
  async lookupTemplate(
    sourcePageIds: string[],
    viewerUserId: string,
    workspaceId: string,
  ): Promise<{ items: PageTemplateLookup[] }> {
    if (sourcePageIds.length === 0) return { items: [] };

    const uniqueIds = Array.from(new Set(sourcePageIds));
    const accessibleSet = new Set(
      await this.filterViewerAccessiblePageIds(
        uniqueIds,
        viewerUserId,
        workspaceId,
      ),
    );

    const accessibleIds = uniqueIds.filter((id) => accessibleSet.has(id));
    const pages = await this.pageRepo.findManyByIds(accessibleIds, {
      workspaceId,
      includeContent: true,
    });
    const pageById = new Map(pages.map((p) => [p.id, p]));

    const items: PageTemplateLookup[] = sourcePageIds.map((sourcePageId) => {
      if (!accessibleSet.has(sourcePageId)) {
        return { sourcePageId, status: 'no_access' as const };
      }
      const page = pageById.get(sourcePageId);
      if (!page) {
        return { sourcePageId, status: 'not_found' as const };
      }

      let content: unknown = null;
      try {
        const pmJson = getProsemirrorContent(page.content);
        const doc = jsonToNode(pmJson);
        content = doc ? removeMarkTypeFromDoc(doc, 'comment').toJSON() : pmJson;
      } catch (err) {
        this.logger.error(
          { err, sourcePageId },
          'Failed to prepare template content for lookup',
        );
        // Never return content carrying the source's comment marks. If the
        // happy-path stripping failed, treat the page as not resolvable.
        return { sourcePageId, status: 'not_found' as const };
      }

      return {
        sourcePageId,
        slugId: page.slugId,
        title: page.title ?? null,
        icon: page.icon ?? null,
        content,
        sourceUpdatedAt: page.updatedAt,
      };
    });

    return { items };
  }

  /**
   * Resolve viewer access for source page IDs supplied by an authenticated
   * caller. Restricts candidates to pages the viewer can see at the space
   * level before applying page-level restrictions, so a workspace member
   * cannot read a sync block from a private space they don't belong to via
   * an unrestricted source page.
   */
  async filterViewerAccessiblePageIds(
    pageIds: string[],
    viewerUserId: string,
    workspaceId: string,
  ): Promise<string[]> {
    if (pageIds.length === 0) return [];

    const spaceVisible = await this.db
      .selectFrom('pages')
      .select('id')
      .where('id', 'in', pageIds)
      .where('workspaceId', '=', workspaceId)
      .where('deletedAt', 'is', null)
      .where(
        'spaceId',
        'in',
        this.spaceMemberRepo.getUserSpaceIdsQuery(viewerUserId),
      )
      .execute();
    if (spaceVisible.length === 0) return [];

    return this.pagePermissionRepo.filterAccessiblePageIds({
      pageIds: spaceVisible.map((r) => r.id),
      userId: viewerUserId,
    });
  }

  async lookup(
    references: Array<{ sourcePageId: string; transclusionId: string }>,
    viewerUserId: string,
    workspaceId: string,
  ): Promise<{ items: TransclusionLookup[] }> {
    if (references.length === 0) return { items: [] };

    const candidatePageIds = Array.from(
      new Set(references.map((r) => r.sourcePageId)),
    );
    const accessibleSet = new Set(
      await this.filterViewerAccessiblePageIds(
        candidatePageIds,
        viewerUserId,
        workspaceId,
      ),
    );

    return this.lookupWithAccessSet(references, accessibleSet, workspaceId);
  }

  /**
   * Resolve transclusion content for the given references using a caller-supplied
   * `accessibleSet` of source page ids. Source pages absent from the set return
   * `no_access`. Used by the share-scoped lookup path, where access is gated by
   * the share graph rather than the viewer's personal permissions.
   */
  async lookupWithAccessSet(
    references: Array<{ sourcePageId: string; transclusionId: string }>,
    accessibleSet: Set<string>,
    workspaceId: string,
  ): Promise<{ items: TransclusionLookup[] }> {
    if (references.length === 0) return { items: [] };

    const items: TransclusionLookup[] = new Array(references.length).fill(null);
    const pendingIdx = references.map((_, i) => i);

    const accessiblePending = pendingIdx.filter((i) =>
      accessibleSet.has(references[i].sourcePageId),
    );
    const rows = await this.pageTransclusionsRepo.findManyByPageAndTransclusion(
      accessiblePending.map((i) => ({
        pageId: references[i].sourcePageId,
        transclusionId: references[i].transclusionId,
      })),
      workspaceId,
    );
    const rowKey = (r: { pageId: string; transclusionId: string }) =>
      `${r.pageId}::${r.transclusionId}`;
    const rowMap = new Map(rows.map((r) => [rowKey(r), r]));

    const accessiblePageIds = Array.from(
      new Set(accessiblePending.map((i) => references[i].sourcePageId)),
    );
    const pages = await this.pageRepo.findManyByIds(accessiblePageIds, {
      workspaceId,
    });
    const pageMeta = new Map<string, Date>();
    for (const p of pages) {
      pageMeta.set(p.id, p.updatedAt);
    }

    for (const i of pendingIdx) {
      const ref = references[i];
      if (!accessibleSet.has(ref.sourcePageId)) {
        items[i] = {
          sourcePageId: ref.sourcePageId,
          transclusionId: ref.transclusionId,
          status: 'no_access',
        };
        continue;
      }
      const updatedAt = pageMeta.get(ref.sourcePageId);
      if (!updatedAt) {
        items[i] = {
          sourcePageId: ref.sourcePageId,
          transclusionId: ref.transclusionId,
          status: 'not_found',
        };
        continue;
      }

      const row = rowMap.get(`${ref.sourcePageId}::${ref.transclusionId}`);
      if (!row) {
        items[i] = {
          sourcePageId: ref.sourcePageId,
          transclusionId: ref.transclusionId,
          status: 'not_found',
        };
        continue;
      }
      items[i] = {
        sourcePageId: ref.sourcePageId,
        transclusionId: ref.transclusionId,
        content: row.content,
        sourceUpdatedAt: updatedAt,
      };
    }

    return { items };
  }

  async listReferences(opts: {
    sourcePageId: string;
    transclusionId: string;
    viewerUserId: string;
    workspaceId: string;
  }): Promise<{
    source: ReferencingPageInfo | null;
    references: ReferencingPageInfo[];
  }> {
    const { sourcePageId, transclusionId, viewerUserId, workspaceId } = opts;

    const referencePageIds =
      await this.pageTransclusionReferencesRepo.findReferencePageIdsByTransclusion(
        sourcePageId,
        transclusionId,
        workspaceId,
      );

    const candidatePageIds = Array.from(
      new Set([sourcePageId, ...referencePageIds]),
    );
    const accessibleSet = new Set(
      await this.filterViewerAccessiblePageIds(
        candidatePageIds,
        viewerUserId,
        workspaceId,
      ),
    );

    const accessibleIds = candidatePageIds.filter((id) =>
      accessibleSet.has(id),
    );
    if (accessibleIds.length === 0) {
      return { source: null, references: [] };
    }

    const rows = await Promise.all(
      accessibleIds.map((id) =>
        this.pageRepo.findById(id, { includeSpace: true }),
      ),
    );
    const byId = new Map<string, ReferencingPageInfo>();
    for (const p of rows) {
      if (!p || p.deletedAt || p.workspaceId !== workspaceId) continue;
      const space = (p as Page & { space?: { slug?: string } }).space;
      byId.set(p.id, {
        id: p.id,
        slugId: p.slugId,
        title: p.title ?? null,
        icon: p.icon ?? null,
        spaceId: p.spaceId,
        spaceSlug: space?.slug ?? null,
      });
    }

    const source = byId.get(sourcePageId) ?? null;
    const references = referencePageIds
      .map((id) => byId.get(id))
      .filter((p): p is ReferencingPageInfo => Boolean(p));

    return { source, references };
  }

  /**
   * Convert a `transclusionReference` into a self-contained copy on the
   * reference page: load source content, generate fresh attachment ids, copy storage
   * files, insert new attachment rows, return rewritten content. The caller
   * (controller) returns the content blob to the client which then performs
   * `editor.commands.insertContentAt(range, content)` to replace the
   * reference node. The next Yjs save naturally cleans up the
   * page_transclusion_references row, but we also delete it eagerly here so a
   * crash between server response and client save doesn't leave a stale row.
   */
  async unsyncReference(
    referencePageId: string,
    sourcePageId: string,
    transclusionId: string,
    user: User,
  ): Promise<{ content: unknown }> {
    const referencePage = await this.pageRepo.findById(referencePageId);
    if (!referencePage || referencePage.deletedAt) {
      throw new NotFoundException('Reference page not found');
    }

    const sourcePage = await this.pageRepo.findById(sourcePageId);
    if (!sourcePage || sourcePage.deletedAt) {
      throw new NotFoundException('Source page not found');
    }

    if (
      referencePage.workspaceId !== user.workspaceId ||
      sourcePage.workspaceId !== user.workspaceId
    ) {
      throw new ForbiddenException();
    }

    await this.pageAccessService.validateCanEdit(referencePage, user);
    await this.pageAccessService.validateCanView(sourcePage, user);

    const transclusion =
      await this.pageTransclusionsRepo.findByPageAndTransclusion(
        sourcePageId,
        transclusionId,
      );
    if (!transclusion) {
      throw new NotFoundException('Sync block not found');
    }

    let content: unknown;
    let copies: ReturnType<typeof rewriteAttachmentsForUnsync>['copies'];
    ({ content, copies } = rewriteAttachmentsForUnsync(
      transclusion.content,
      () => uuid7(),
    ));

    if (copies.length > 0) {
      const oldIds = copies.map((c) => c.oldAttachmentId);
      const oldRows = await this.attachmentRepo.findByIds(oldIds);
      const byOldId = new Map(
        oldRows
          .filter((a) => a.pageId === sourcePageId)
          .map((a) => [a.id, a]),
      );

      for (const plan of copies) {
        const old = byOldId.get(plan.oldAttachmentId);
        if (!old) continue;

        const newFilePath = old.filePath
          .split(plan.oldAttachmentId)
          .join(plan.newAttachmentId);
        try {
          await this.storageService.copy(old.filePath, newFilePath);
        } catch (err) {
          this.logger.error(
            `unsync: failed to copy attachment ${old.id}`,
            err as Error,
          );
          continue;
        }
        await this.attachmentRepo.insertAttachment({
          id: plan.newAttachmentId,
          type: old.type,
          filePath: newFilePath,
          fileName: old.fileName,
          fileSize: old.fileSize,
          mimeType: old.mimeType,
          fileExt: old.fileExt,
          creatorId: user.id,
          workspaceId: referencePage.workspaceId,
          pageId: referencePageId,
          spaceId: referencePage.spaceId,
        });
      }
    }

    await this.pageTransclusionReferencesRepo.deleteOne(
      referencePageId,
      sourcePageId,
      transclusionId,
    );

    return { content };
  }
}
