import { Injectable } from '@nestjs/common';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { WsService } from './ws.service';
import {
  PageMovedEvent,
  TreeNodeSnapshot,
  TreeUpdateSnapshot,
} from '../database/listeners/page.listener';

@Injectable()
export class WsTreeService {
  constructor(
    private readonly wsService: WsService,
    private readonly pagePermissionRepo: PagePermissionRepo,
  ) {}

  // Server-origin tree broadcasts. Built from thin node snapshots carried in the
  // domain events (variant A) so no DB read happens here — this avoids the
  // in-transaction visibility race. Payload shapes mirror what the client
  // receiver (`use-tree-socket.ts`) consumes.

  async broadcastPageCreated(page: TreeNodeSnapshot): Promise<void> {
    await this.wsService.emitTreeEvent(page.spaceId, page.id, {
      operation: 'addTreeNode',
      spaceId: page.spaceId,
      payload: {
        parentId: page.parentPageId ?? null,
        // Receivers place by `position` among already-loaded siblings, not by
        // this absolute index (sender's loaded set differs from receivers').
        index: 0,
        data: {
          id: page.id,
          slugId: page.slugId,
          name: page.title ?? '',
          title: page.title,
          icon: page.icon,
          position: page.position,
          spaceId: page.spaceId,
          parentPageId: page.parentPageId,
          hasChildren: false,
          children: [],
        },
      },
    });
  }

  // Rename / icon change: patch the in-tree node's title/icon on every client in
  // the space. Routed through the restriction-aware `emitTreeEvent` so a
  // restricted page's new title/icon never leaks to sockets that can't see it.
  // The payload mirrors the client `UpdateEvent` shape consumed by
  // `applyUpdateOne` (entity ["pages"], `id`, `payload.title` / `payload.icon`);
  // only the fields that actually changed are sent (the snapshot omits the rest).
  async broadcastPageUpdated(node: TreeUpdateSnapshot): Promise<void> {
    await this.wsService.emitTreeEvent(node.spaceId, node.id, {
      operation: 'updateOne',
      spaceId: node.spaceId,
      entity: ['pages'],
      id: node.id,
      payload: {
        slugId: node.slugId,
        parentPageId: node.parentPageId,
        // Only include changed fields; an absent field leaves the client node
        // untouched (applyUpdateOne checks `!== undefined` per field).
        ...(node.title !== undefined ? { title: node.title } : {}),
        ...(node.icon !== undefined ? { icon: node.icon } : {}),
      },
    });
  }

  async broadcastPageDeleted(page: TreeNodeSnapshot): Promise<void> {
    await this.wsService.emitTreeEvent(page.spaceId, page.id, {
      operation: 'deleteTreeNode',
      spaceId: page.spaceId,
      payload: {
        node: {
          id: page.id,
          slugId: page.slugId,
          parentPageId: page.parentPageId ?? null,
        },
      },
    });
  }

  async broadcastPageMoved(event: PageMovedEvent): Promise<void> {
    const { node } = event;

    const movePayload = {
      operation: 'moveTreeNode',
      spaceId: node.spaceId,
      payload: {
        id: node.id,
        parentId: node.parentPageId ?? null,
        oldParentId: event.oldParentId ?? null,
        // See broadcastPageCreated: receivers place by `position`, not index.
        index: 0,
        position: node.position,
        pageData: {
          id: node.id,
          slugId: node.slugId,
          title: node.title,
          icon: node.icon,
          position: node.position,
          spaceId: node.spaceId,
          parentPageId: node.parentPageId ?? null,
          hasChildren: event.hasChildren,
        },
      },
    };

    // Decide the node's restricted state ONCE, fresh (uncached), and drive BOTH
    // the move broadcast and the compensating delete from this single decision.
    //
    // Why not just emitTreeEvent for the move? emitTreeEvent gates the move on
    // the CACHED spaceHasRestrictions (30s TTL, never invalidated). In the window
    // right after a space gets its FIRST restriction, that cache still says
    // "no restrictions" → emitTreeEvent would fan the move out to the WHOLE room
    // (including unauthorized users) while the delete below (computed from the
    // UNCACHED hasRestrictedAncestor) also fires. An unauthorized user then gets
    // BOTH, and if the delete lands first it is a no-op and the later move
    // renders the restricted node → leak. So when the node is known-restricted we
    // must NOT route the move through the cache-gated path.
    const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
      node.id,
    );

    if (!isRestricted) {
      // Normal case: not under a restricted ancestor. One moveTreeNode to the
      // whole space room (emitTreeEvent's open-space fast path), no delete.
      await this.wsService.emitTreeEvent(node.spaceId, node.id, movePayload);
      return;
    }

    // Restricted case: a move can push a previously-visible page UNDER a
    // restricted ancestor. The move (to authorized users) and the compensating
    // delete (to everyone else) are now driven from ONE socket/access snapshot:
    // emitMoveWithRestrictionSplit performs a single fetchSockets + a single
    // getUserIdsWithPageAccess and partitions the room from that one snapshot.
    // This eliminates the race window that existed when the move and the delete
    // each resolved the audience independently — a socket could otherwise have
    // landed in both sets (leaking the restricted node) or in neither (losing the
    // compensating delete). Authorized users get exactly the moveTreeNode,
    // everyone else (unauthorized + anonymous) gets exactly the deleteTreeNode.
    //
    // Users who LOSE visibility need the delete because otherwise the node would
    // linger in their tree at its old parent with its real title/slugId/icon
    // (existence + metadata leak).
    await this.wsService.emitMoveWithRestrictionSplit(node.spaceId, node.id, movePayload, {
      operation: 'deleteTreeNode',
      spaceId: node.spaceId,
      payload: {
        node: {
          id: node.id,
          slugId: node.slugId,
          parentPageId: event.oldParentId ?? null,
        },
      },
    });
  }

  // Used for restore (and other subtree re-attachments): rather than emitting N
  // pointwise addTreeNode events, ask clients in the space to refetch the root
  // tree. The client already understands `refetchRootTreeNodeEvent`.
  async broadcastRefetchRoot(spaceId: string): Promise<void> {
    this.wsService.emitToSpaceRoom(spaceId, {
      operation: 'refetchRootTreeNodeEvent',
      spaceId,
    });
  }
}
