import { and, asc, eq, sql } from 'drizzle-orm';

import { db } from '@/lib/db';
import { documentFolders, documents, files, type DocumentFolder } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { interests } from '@/lib/db/schema/interests';
import { createAuditLog } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';

/**
 * Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
 * raised specifically on the sibling-name unique index. Narrowing to this
 * constraint prevents swallowing unrelated unique violations.
 */
function isSiblingNameConflict(err: unknown): boolean {
  if (!err || typeof err !== 'object') return false;
  const e = err as {
    code?: unknown;
    constraint_name?: unknown;
    constraint?: unknown;
    cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
  };
  const code = e.code ?? e.cause?.code;
  if (code !== '23505') return false;
  const constraint =
    e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
  return constraint === 'uniq_document_folders_sibling_name';
}

/**
 * Throws ConflictError if the folder is system-managed. Centralises the
 * rejection so rename/move/delete all surface identical error shapes.
 */
async function assertNotSystemManaged(
  portId: string,
  folderId: string,
  action: 'rename' | 'move' | 'delete',
): Promise<DocumentFolder> {
  const folder = await db.query.documentFolders.findFirst({
    where: and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)),
  });
  if (!folder) throw new NotFoundError('Folder');
  if (folder.systemManaged) {
    const verb = action === 'rename' ? 'renamed' : action === 'move' ? 'moved' : 'deleted';
    throw new ConflictError(`System folders can't be ${verb}`);
  }
  return folder;
}

export interface FolderNode extends DocumentFolder {
  children: FolderNode[];
}

/**
 * Returns the entire folder tree for a port, nested under their
 * parents. Roots come back at the top level. Order is alphabetical
 * (case-insensitive) within each parent - matches the sibling-uniqueness
 * index ordering and gives reps a stable browsing experience.
 *
 * Uses a single SELECT + JS nesting rather than a recursive CTE; the
 * folder tree is small (UI gates depth; thousands of folders would be
 * a misuse) so the in-memory build is cheaper than a CTE round-trip.
 */
export async function listTree(portId: string): Promise<FolderNode[]> {
  const rows = await db
    .select()
    .from(documentFolders)
    .where(eq(documentFolders.portId, portId))
    .orderBy(asc(documentFolders.name));

  const byId = new Map<string, FolderNode>();
  for (const row of rows) byId.set(row.id, { ...row, children: [] });

  const roots: FolderNode[] = [];
  for (const node of byId.values()) {
    if (node.parentId === null) {
      roots.push(node);
    } else {
      const parent = byId.get(node.parentId);
      if (parent) {
        parent.children.push(node);
      } else {
        // Orphan rows (parentId pointing nowhere) are dropped from the
        // tree but stay in the DB. Surface via a separate maintenance
        // query if needed; never silently re-parent.
        logger.warn(
          { portId, folderId: node.id, parentId: node.parentId, name: node.name },
          'listTree: orphan folder row (parentId points to a missing folder); dropped from tree',
        );
      }
    }
  }
  return roots;
}

interface CreateFolderInput {
  name: string;
  parentId: string | null;
}

/**
 * Creates a folder under the given parent. Throws ConflictError when
 * a sibling with the same case-insensitive name already exists (the
 * DB unique index is the authoritative guard; this maps the Postgres
 * 23505 to the typed error). Throws ValidationError when `parentId`
 * doesn't belong to this port (cross-port leakage guard).
 */
export async function createFolder(
  portId: string,
  userId: string,
  data: CreateFolderInput,
): Promise<DocumentFolder> {
  const trimmed = data.name.trim();
  if (!trimmed) throw new ValidationError('Folder name cannot be empty');
  if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars');

  if (data.parentId !== null) {
    const parent = await db.query.documentFolders.findFirst({
      where: and(eq(documentFolders.id, data.parentId), eq(documentFolders.portId, portId)),
    });
    if (!parent) throw new ValidationError('Invalid parent folder');
  }

  try {
    const [row] = await db
      .insert(documentFolders)
      .values({
        portId,
        parentId: data.parentId,
        name: trimmed,
        createdBy: userId,
      })
      .returning();
    if (!row) throw new NotFoundError('Folder');

    void createAuditLog({
      userId,
      portId,
      action: 'create',
      entityType: 'document_folder',
      entityId: row.id,
      newValue: { name: row.name, parentId: row.parentId },
      metadata: { type: 'folder_created' },
    });

    return row;
  } catch (err) {
    if (isSiblingNameConflict(err)) {
      throw new ConflictError(`A folder named "${trimmed}" already exists here`);
    }
    throw err;
  }
}

/**
 * Renames a folder. Throws ConflictError if a sibling with the same
 * case-insensitive name already exists. Throws NotFoundError if the
 * folder doesn't belong to the given port (cross-port leakage guard).
 */
export async function renameFolder(
  portId: string,
  folderId: string,
  newName: string,
  userId: string,
): Promise<DocumentFolder> {
  const trimmed = newName.trim();
  if (!trimmed) throw new ValidationError('Folder name cannot be empty');
  if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars');

  const existing = await assertNotSystemManaged(portId, folderId, 'rename');

  try {
    const [updated] = await db
      .update(documentFolders)
      .set({ name: trimmed, updatedAt: new Date() })
      .where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)))
      .returning();
    if (!updated) throw new NotFoundError('Folder');

    void createAuditLog({
      userId,
      portId,
      action: 'update',
      entityType: 'document_folder',
      entityId: folderId,
      oldValue: { name: existing.name },
      newValue: { name: trimmed },
    });

    return updated;
  } catch (err) {
    if (isSiblingNameConflict(err)) {
      throw new ConflictError(`A folder named "${trimmed}" already exists here`);
    }
    throw err;
  }
}

/**
 * Moves a folder to a new parent (or to root with newParentId=null).
 * Walks the destination's ancestor chain to detect cycles before
 * writing. Throws ValidationError for cycle/invalid-parent, NotFoundError
 * for cross-port access.
 */
export async function moveFolder(
  portId: string,
  folderId: string,
  newParentId: string | null,
  userId: string,
): Promise<DocumentFolder> {
  if (newParentId === folderId) {
    throw new ValidationError('Cannot move a folder under itself (cycle)');
  }

  const folder = await assertNotSystemManaged(portId, folderId, 'move');

  // concurrency-auditor HIGH: the cycle check + the UPDATE used to run
  // as separate statements with no shared lock. Two concurrent moves
  // (move A → B and move B → A) could each pass the cycle check
  // against the pre-move tree, then both write, leaving an A↔B cycle.
  // Wrap the whole sequence in a single transaction so the walk-and-
  // write is atomic per move attempt.
  try {
    return await db.transaction(async (tx) => {
      // Serialize all folder-move work for this port via a per-port
      // advisory lock. The cycle check walks the ancestor chain with
      // multiple SELECTs, and READ COMMITTED doesn't see other in-flight
      // updates without an explicit lock. Two concurrent moves (A → B
      // and B → A) would otherwise each see the pre-state and both
      // commit, leaving an A↔B cycle. The lock auto-releases on tx end.
      await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${portId} || ':folder-move'))`);

      if (newParentId !== null) {
        const newParent = await tx.query.documentFolders.findFirst({
          where: and(eq(documentFolders.id, newParentId), eq(documentFolders.portId, portId)),
        });
        if (!newParent) throw new ValidationError('Invalid parent folder');

        // Cycle check: walk the destination's ancestor chain. If we hit
        // folderId, the destination is a descendant of the folder being
        // moved - moving would create a cycle.
        let cursor: string | null = newParent.parentId;
        const seen = new Set<string>([newParent.id]);
        while (cursor) {
          if (cursor === folderId) {
            throw new ValidationError('Cannot move a folder under one of its descendants (cycle)');
          }
          if (seen.has(cursor)) break; // defensive - pre-existing cycle, bail
          seen.add(cursor);
          const next = await tx.query.documentFolders.findFirst({
            where: and(eq(documentFolders.id, cursor), eq(documentFolders.portId, portId)),
            columns: { parentId: true },
          });
          cursor = next?.parentId ?? null;
        }
      }

      const [updated] = await tx
        .update(documentFolders)
        .set({ parentId: newParentId, updatedAt: new Date() })
        .where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)))
        .returning();
      if (!updated) throw new NotFoundError('Folder');

      void createAuditLog({
        userId,
        portId,
        action: 'update',
        entityType: 'document_folder',
        entityId: folderId,
        oldValue: { parentId: folder.parentId },
        newValue: { parentId: newParentId },
        metadata: { type: 'folder_move' },
      });

      return updated;
    });
  } catch (err) {
    if (isSiblingNameConflict(err)) {
      throw new ConflictError('A folder with that name already exists in the destination');
    }
    throw err;
  }
}

/**
 * Soft-rescue delete: re-parent every child folder + every linked
 * document to the deleted folder's parent (or to root if the deleted
 * folder is at root). Audit-logged. Wrapped in a transaction so
 * partial failures don't leave dangling rows.
 */
export async function deleteFolderSoftRescue(
  portId: string,
  folderId: string,
  userId: string,
): Promise<void> {
  const folder = await assertNotSystemManaged(portId, folderId, 'delete');

  const newParent = folder.parentId; // null = re-parent to root

  await db.transaction(async (tx) => {
    await tx
      .update(documentFolders)
      .set({ parentId: newParent, updatedAt: new Date() })
      .where(and(eq(documentFolders.parentId, folderId), eq(documentFolders.portId, portId)));

    await tx
      .update(documents)
      .set({ folderId: newParent })
      .where(and(eq(documents.folderId, folderId), eq(documents.portId, portId)));

    // G-C1: files.folder_id is ON DELETE SET NULL - without this UPDATE,
    // files in the deleted folder would scatter to root while documents
    // in the same folder land at the deleted folder's parent. Re-parent
    // files explicitly so the soft-rescue is symmetric across both.
    await tx
      .update(files)
      .set({ folderId: newParent })
      .where(and(eq(files.folderId, folderId), eq(files.portId, portId)));

    await tx
      .delete(documentFolders)
      .where(and(eq(documentFolders.id, folderId), eq(documentFolders.portId, portId)));
  });

  void createAuditLog({
    userId,
    portId,
    action: 'delete',
    entityType: 'document_folder',
    entityId: folderId,
    oldValue: { name: folder.name, parentId: folder.parentId },
    metadata: { rescuedTo: newParent },
  });
}

/**
 * Walk a folder tree and return the IDs of every descendant of `rootId`
 * (NOT including rootId itself). Used by `listDocuments` when
 * `includeDescendants=true` so a port-wide tree fetch is reused
 * instead of a recursive CTE.
 */
export function collectDescendantIds(tree: FolderNode[], rootId: string): string[] {
  const out: string[] = [];
  function visit(nodes: FolderNode[], inside: boolean) {
    for (const n of nodes) {
      if (inside || n.id === rootId) {
        if (n.id !== rootId) out.push(n.id);
        visit(n.children, true);
      } else {
        visit(n.children, false);
      }
    }
  }
  visit(tree, false);
  return out;
}

const SYSTEM_ROOT_NAMES = ['Clients', 'Companies', 'Yachts'] as const;
type SystemRootName = (typeof SYSTEM_ROOT_NAMES)[number];

/**
 * Idempotently create the three system root folders for a port
 * (`Clients/`, `Companies/`, `Yachts/`). Returns the rows in stable
 * order. Safe to call on every port-init and on every backfill run.
 *
 * Uses INSERT … ON CONFLICT … DO NOTHING via the sibling-name unique
 * index (`uniq_document_folders_sibling_name`) so a concurrent caller
 * can't race two inserts of the same root. Re-SELECTs on conflict so
 * the return shape is always populated.
 */
export async function ensureSystemRoots(portId: string, userId: string): Promise<DocumentFolder[]> {
  const values = SYSTEM_ROOT_NAMES.map((name) => ({
    portId,
    parentId: null,
    name,
    systemManaged: true,
    entityType: 'root' as const,
    entityId: null,
    createdBy: userId,
  }));

  // ON CONFLICT DO NOTHING with no target is safe here because root
  // inserts can only collide on `uniq_document_folders_sibling_name`
  // (entityId is null on roots, so the partial index
  // `uniq_document_folders_entity` is excluded). Do not copy this
  // pattern into helpers that insert per-entity subfolders - they
  // need an explicit target to avoid masking real conflicts.
  await db.insert(documentFolders).values(values).onConflictDoNothing();

  const rows = await db
    .select()
    .from(documentFolders)
    .where(and(eq(documentFolders.portId, portId), eq(documentFolders.entityType, 'root')));

  return SYSTEM_ROOT_NAMES.map((name: SystemRootName) => {
    const row = rows.find((r) => r.name === name);
    if (!row) {
      logger.error(
        { portId, missingRoot: name, foundNames: rows.map((r) => r.name) },
        'ensureSystemRoots: invariant violated - system root missing after upsert',
      );
      throw new Error(`ensureSystemRoots: missing root ${name} after upsert`);
    }
    return row;
  });
}

// ─── ensureEntityFolder ──────────────────────────────────────────────────────

export type EntityType = 'client' | 'company' | 'yacht' | 'interest';
const ENTITY_TYPES = new Set<EntityType>(['client', 'company', 'yacht', 'interest']);

/**
 * Returns the display name for an entity used to label its subfolder.
 * Clients use `fullName` verbatim (matching rep-facing list views).
 * Companies and yachts use their `name` column verbatim. Interest
 * folders are derived from their primary berth (when set) so the
 * tree reads "Acme Corp / A1-A3" rather than a meaningless interest
 * UUID; falls back to the createdAt date as a stable label when no
 * primary berth is linked yet.
 */
async function resolveEntityDisplayName(
  portId: string,
  entityType: EntityType,
  entityId: string,
): Promise<string> {
  if (entityType === 'client') {
    const c = await db.query.clients.findFirst({
      where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
      columns: { fullName: true },
    });
    if (!c) throw new NotFoundError('Client');
    return c.fullName;
  }
  if (entityType === 'company') {
    const co = await db.query.companies.findFirst({
      where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
      columns: { name: true },
    });
    if (!co) throw new NotFoundError('Company');
    return co.name;
  }
  if (entityType === 'interest') {
    const i = await db.query.interests.findFirst({
      where: and(eq(interests.id, entityId), eq(interests.portId, portId)),
      columns: { createdAt: true },
    });
    if (!i) throw new NotFoundError('Interest');
    // Defer to the interest-berths service for the primary berth label
    // - circular-dep avoidance via dynamic import. Falls back to the
    // ISO date slice ("Deal 2026-05-12") when no berth is linked yet.
    const { getPrimaryBerth } = await import('@/lib/services/interest-berths.service');
    const primary = await getPrimaryBerth(entityId).catch(() => null);
    if (primary?.mooringNumber) return `Deal - ${primary.mooringNumber}`;
    const dateSlice = i.createdAt.toISOString().slice(0, 10);
    return `Deal ${dateSlice}`;
  }
  // yacht
  const y = await db.query.yachts.findFirst({
    where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
    columns: { name: true },
  });
  if (!y) throw new NotFoundError('Yacht');
  return y.name;
}

/**
 * Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
 * on the per-entity unique index (`uniq_document_folders_entity`). Narrowing
 * to this specific constraint lets callers distinguish a concurrent-winner
 * race (same entity_id inserted twice) from a sibling-name collision.
 */
function isEntityFolderConflict(err: unknown): boolean {
  if (!err || typeof err !== 'object') return false;
  const e = err as {
    code?: unknown;
    constraint_name?: unknown;
    constraint?: unknown;
    cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
  };
  const code = e.code ?? e.cause?.code;
  if (code !== '23505') return false;
  const constraint =
    e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
  return constraint === 'uniq_document_folders_entity';
}

/**
 * Idempotently create the per-entity subfolder under the matching system
 * root (`Clients/` / `Companies/` / `Yachts/`). Returns the folder row
 * regardless of whether it was newly created or already existed.
 *
 * Concurrent callers race safely via the partial unique index
 * `uniq_document_folders_entity` - the loser INSERT errors and the
 * re-SELECT returns the winner's row.
 *
 * On sibling-name collision (two entities want the same display name),
 * appends a numeric suffix `(2)`, `(3)`, …, until the insert succeeds.
 * The `system_managed` flag stays true on the suffixed folder.
 *
 * Note: this helper deliberately does NOT use the no-args
 * onConflictDoNothing() pattern from ensureSystemRoots. Entity inserts
 * can conflict on EITHER uniq_document_folders_entity (race winner
 * already inserted the same entity_id) OR uniq_document_folders_sibling_name
 * (different entity, same display name). We need to discriminate so we
 * can retry-with-suffix on the name collision and re-SELECT-and-return
 * on the entity-id collision.
 */
export async function ensureEntityFolder(
  portId: string,
  entityType: EntityType,
  entityId: string,
  userId: string,
): Promise<DocumentFolder> {
  if (!ENTITY_TYPES.has(entityType)) {
    throw new ValidationError(`Unknown entity type: ${entityType}`);
  }

  // Fast path: row already exists.
  const existing = await db.query.documentFolders.findFirst({
    where: and(
      eq(documentFolders.portId, portId),
      eq(documentFolders.entityType, entityType),
      eq(documentFolders.entityId, entityId),
    ),
  });
  if (existing) return existing;

  // Resolve the parent folder. For client / company / yacht this is the
  // matching system root. For interest, parent is the owning client's
  // entity folder so the tree renders nested: Clients/Acme/Deal A1-A3/.
  let parent: DocumentFolder | undefined;
  if (entityType === 'interest') {
    const interestRow = await db.query.interests.findFirst({
      where: and(eq(interests.id, entityId), eq(interests.portId, portId)),
      columns: { clientId: true },
    });
    if (!interestRow) throw new NotFoundError('Interest');
    // Recursively ensure the parent client's folder first - guarantees
    // we always land inside the existing Clients/<Name>/ subfolder even
    // when the deal's first artifact predates any client-level upload.
    parent = await ensureEntityFolder(portId, 'client', interestRow.clientId, userId);
  } else {
    const rootName: SystemRootName =
      entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts';
    parent = await db.query.documentFolders.findFirst({
      where: and(
        eq(documentFolders.portId, portId),
        eq(documentFolders.entityType, 'root'),
        eq(documentFolders.name, rootName),
      ),
    });
    if (!parent) {
      // Self-heal: the port-init hook may have been skipped (legacy port).
      await ensureSystemRoots(portId, userId);
      return ensureEntityFolder(portId, entityType, entityId, userId);
    }
  }
  const root = parent;

  const baseName = await resolveEntityDisplayName(portId, entityType, entityId);

  // Try the base name first; on sibling-name collision, append (2), (3)...
  for (let attempt = 0; attempt < 50; attempt += 1) {
    const candidate = attempt === 0 ? baseName : `${baseName} (${attempt + 1})`;
    try {
      const [row] = await db
        .insert(documentFolders)
        .values({
          portId,
          parentId: root.id,
          name: candidate,
          systemManaged: true,
          entityType,
          entityId,
          createdBy: userId,
        })
        .returning();
      if (!row) throw new Error('ensureEntityFolder: insert returned no row');
      return row;
    } catch (err) {
      // If another caller won the entity-id race, re-SELECT and return their row.
      if (isEntityFolderConflict(err)) {
        const winner = await db.query.documentFolders.findFirst({
          where: and(
            eq(documentFolders.portId, portId),
            eq(documentFolders.entityType, entityType),
            eq(documentFolders.entityId, entityId),
          ),
        });
        if (winner) return winner;
      }
      // Sibling-name collision (different entity, same name) → bump suffix and retry.
      if (isSiblingNameConflict(err)) continue;
      throw err;
    }
  }
  logger.warn(
    { portId, entityType, entityId, baseName, attempts: 50 },
    'ensureEntityFolder: exhausted 50 suffix attempts without finding a unique name',
  );
  throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
}

/**
 * Rename the per-entity subfolder to match the entity's current display
 * name. Called from the entity rename services (`updateClient`,
 * `updateCompany`, `updateYacht`). No-op when the folder does not exist
 * (lazy creation - entities without a folder skip the sync entirely).
 *
 * Sibling-name collision is resolved by suffix bump (matches
 * `ensureEntityFolder` semantics).
 *
 * Intentionally does NOT call `assertNotSystemManaged` - this helper
 * is the legitimate path for renaming a system folder.
 */
export async function syncEntityFolderName(
  portId: string,
  entityType: EntityType,
  entityId: string,
  userId: string,
): Promise<void> {
  if (!ENTITY_TYPES.has(entityType)) return;

  const folder = await db.query.documentFolders.findFirst({
    where: and(
      eq(documentFolders.portId, portId),
      eq(documentFolders.entityType, entityType),
      eq(documentFolders.entityId, entityId),
    ),
  });
  if (!folder) return; // Lazy creation - nothing to sync yet.

  // Preserve archived suffix if present.
  const isArchived = folder.name.endsWith(' (archived)');
  const isDeleted = folder.name.endsWith(' (deleted)');
  if (isDeleted) return; // Demoted; rep owns the name now.

  const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
  const targetSuffix = isArchived ? ' (archived)' : '';

  for (let attempt = 0; attempt < 50; attempt += 1) {
    const candidate =
      attempt === 0 ? `${baseName}${targetSuffix}` : `${baseName} (${attempt + 1})${targetSuffix}`;
    if (candidate === folder.name) return; // No-op rename.
    try {
      const [updated] = await db
        .update(documentFolders)
        .set({ name: candidate, updatedAt: new Date() })
        .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)))
        .returning();
      if (updated) {
        void createAuditLog({
          userId,
          portId,
          action: 'update',
          entityType: 'document_folder',
          entityId: folder.id,
          oldValue: { name: folder.name },
          newValue: { name: candidate },
          metadata: {
            type: 'folder_entity_rename_sync',
            entity: entityType,
            sourceEntityId: entityId,
          },
        });
        return;
      }
    } catch (err) {
      if (isSiblingNameConflict(err)) continue;
      throw err;
    }
  }
  logger.warn(
    { portId, entityType, entityId, baseName, attempts: 50 },
    'syncEntityFolderName: exhausted 50 suffix attempts without finding a unique name',
  );
  throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
}

// ─── Archive / Restore / Demote helpers ──────────────────────────────────────

const ARCHIVED_SUFFIX = ' (archived)';
const DELETED_SUFFIX = ' (deleted)';

/**
 * Stamp an entity's subfolder as archived: append " (archived)" to the
 * name (idempotent - won't double-append) and set archived_at. No-op
 * when the folder does not exist (lazy creation). Used by the entity
 * archive paths in clients / companies / yachts services.
 */
export async function applyEntityArchivedSuffix(
  portId: string,
  entityType: EntityType,
  entityId: string,
  userId?: string,
): Promise<void> {
  if (!ENTITY_TYPES.has(entityType)) return;
  const folder = await db.query.documentFolders.findFirst({
    where: and(
      eq(documentFolders.portId, portId),
      eq(documentFolders.entityType, entityType),
      eq(documentFolders.entityId, entityId),
    ),
  });
  if (!folder) return;
  const newName = folder.name.endsWith(ARCHIVED_SUFFIX)
    ? folder.name
    : `${folder.name}${ARCHIVED_SUFFIX}`;
  if (newName === folder.name && folder.archivedAt) return; // Already archived, no-op.
  await db
    .update(documentFolders)
    .set({ name: newName, archivedAt: new Date(), updatedAt: new Date() })
    .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));

  void createAuditLog({
    userId: userId ?? 'system',
    portId,
    action: 'archive',
    entityType: 'document_folder',
    entityId: folder.id,
    oldValue: { name: folder.name, archivedAt: folder.archivedAt },
    newValue: { name: newName, archivedAt: new Date() },
    metadata: { type: 'folder_entity_archived', entity: entityType, sourceEntityId: entityId },
  });
}

/**
 * Inverse of `applyEntityArchivedSuffix` - strip " (archived)" from
 * the name and clear archived_at. No-op when the folder does not
 * exist or wasn't archived.
 */
export async function applyEntityRestoredSuffix(
  portId: string,
  entityType: EntityType,
  entityId: string,
  userId?: string,
): Promise<void> {
  if (!ENTITY_TYPES.has(entityType)) return;
  const folder = await db.query.documentFolders.findFirst({
    where: and(
      eq(documentFolders.portId, portId),
      eq(documentFolders.entityType, entityType),
      eq(documentFolders.entityId, entityId),
    ),
  });
  if (!folder) return;
  const newName = folder.name.endsWith(ARCHIVED_SUFFIX)
    ? folder.name.slice(0, -ARCHIVED_SUFFIX.length)
    : folder.name;
  if (newName === folder.name && !folder.archivedAt) return; // Wasn't archived, no-op.
  await db
    .update(documentFolders)
    .set({ name: newName, archivedAt: null, updatedAt: new Date() })
    .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));

  void createAuditLog({
    userId: userId ?? 'system',
    portId,
    action: 'restore',
    entityType: 'document_folder',
    entityId: folder.id,
    oldValue: { name: folder.name, archivedAt: folder.archivedAt },
    newValue: { name: newName, archivedAt: null },
    metadata: { type: 'folder_entity_restored', entity: entityType, sourceEntityId: entityId },
  });
}

/**
 * Entity has been hard-deleted: demote the folder to a regular user
 * folder by clearing `system_managed`, appending " (deleted)" to the
 * name, and dropping the entity FK so the partial unique index no
 * longer constrains it. Files still inside the folder retain their
 * snapshotted entity FKs (orphaned - they appear in the root-view
 * Files section once the rep cleans up).
 *
 * Idempotent: re-demoting an already-demoted folder is a no-op because
 * the entityType + entityId FK is cleared on first demotion.
 */
export async function demoteSystemFolderOnEntityDelete(
  portId: string,
  entityType: EntityType,
  entityId: string,
): Promise<void> {
  if (!ENTITY_TYPES.has(entityType)) return;
  const folder = await db.query.documentFolders.findFirst({
    where: and(
      eq(documentFolders.portId, portId),
      eq(documentFolders.entityType, entityType),
      eq(documentFolders.entityId, entityId),
    ),
  });
  if (!folder) return;
  const stripped = folder.name.endsWith(ARCHIVED_SUFFIX)
    ? folder.name.slice(0, -ARCHIVED_SUFFIX.length)
    : folder.name;
  const newName = stripped.endsWith(DELETED_SUFFIX) ? stripped : `${stripped}${DELETED_SUFFIX}`;
  await db
    .update(documentFolders)
    .set({
      name: newName,
      systemManaged: false,
      entityType: null,
      entityId: null,
      archivedAt: null,
      updatedAt: new Date(),
    })
    .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
}

/**
 * Phase 2 nested-subfolders lifecycle hook. Re-renames an interest's
 * document folder when its outcome changes - e.g. `Deal A1-A3` becomes
 * `Deal A1-A3 (Won)`, `(Lost)`, or `(Cancelled)`. No-op when the
 * folder doesn't exist yet (uploads happen later) or when the outcome
 * is null (still in flight).
 *
 * Called from interests.service.setInterestOutcome via dynamic import
 * to avoid the circular dep with this module's primary-berth label
 * resolver.
 */
export async function renameInterestFolderForOutcome(
  interestId: string,
  portId: string,
  outcome: string | null,
): Promise<void> {
  if (!outcome) return;
  const folder = await db.query.documentFolders.findFirst({
    where: and(
      eq(documentFolders.portId, portId),
      eq(documentFolders.entityType, 'interest'),
      eq(documentFolders.entityId, interestId),
    ),
  });
  if (!folder) return;
  // Strip any prior outcome suffix so re-running the hook (e.g. when
  // outcome flips from lost → won) doesn't accumulate parens.
  const baseName = folder.name.replace(/\s*\((Won|Lost|Cancelled)\)\s*$/i, '');
  const label = outcome === 'won' ? 'Won' : outcome === 'lost' ? 'Lost' : 'Cancelled';
  const newName = `${baseName} (${label})`;
  if (newName === folder.name) return;
  await db
    .update(documentFolders)
    .set({ name: newName, updatedAt: new Date() })
    .where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
}
