/**
 * trash-cleanup.ts
 *
 * Periodic GC for `data/users/{ownerId}/trash/`. Files older than
 * `retentionDays` (mtime-based) are unlinked. Empty subdirectories left
 * behind are removed too.
 *
 * Trigger: one sweep at boot + setInterval(24h). The interval is unref()'d
 * so it does not keep the event loop alive on its own.
 */

import { promises as fs } from 'node:fs';
import type { Dirent } from 'node:fs';
import { join } from 'node:path';
import { logger } from '../logger.js';

const DAY_MS = 86_400_000;
const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;

export interface RunTrashCleanupResult {
  scannedUsers: number;
  deletedFiles: number;
  freedBytes: number;
}

/**
 * Walk `{rootDir}/{userId}/trash/` for every user and delete files whose
 * mtime is older than `retentionDays`. With retentionDays=0 every file is
 * eligible (useful for tests / aggressive cleanup).
 */
export async function runTrashCleanup(
  rootDir: string,
  retentionDays: number,
): Promise<RunTrashCleanupResult> {
  const result: RunTrashCleanupResult = {
    scannedUsers: 0,
    deletedFiles: 0,
    freedBytes: 0,
  };

  if (!Number.isFinite(retentionDays) || retentionDays < 0) {
    throw new Error(`runTrashCleanup: retentionDays must be a non-negative finite number, got ${retentionDays}`);
  }

  // retentionDays=0 → delete everything regardless of mtime. We can't just compute
  // `Date.now() - 0` because file mtime can have sub-ms precision slightly newer than
  // the integer-truncated Date.now(), which would skip a just-written file.
  const cutoff = retentionDays === 0 ? Number.POSITIVE_INFINITY : Date.now() - retentionDays * DAY_MS;

  let userEntries: Dirent[];
  try {
    userEntries = await fs.readdir(rootDir, { withFileTypes: true });
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    if (e.code === 'ENOENT') return result; // user folder root does not exist yet — nothing to do
    throw err;
  }

  for (const userEntry of userEntries) {
    if (!userEntry.isDirectory()) continue;
    const trashDir = join(rootDir, userEntry.name, 'trash');
    let stat;
    try {
      stat = await fs.stat(trashDir);
    } catch (err) {
      const e = err as NodeJS.ErrnoException;
      if (e.code === 'ENOENT') continue;
      logger.warn(`[trash-cleanup] stat failed user=${userEntry.name} err=${e.message}`);
      continue;
    }
    if (!stat.isDirectory()) continue;
    result.scannedUsers++;
    await sweepDir(trashDir, cutoff, result, userEntry.name);
  }

  return result;
}

async function sweepDir(
  dir: string,
  cutoff: number,
  result: RunTrashCleanupResult,
  userId: string,
): Promise<void> {
  let entries: Dirent[];
  try {
    entries = await fs.readdir(dir, { withFileTypes: true });
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    logger.warn(`[trash-cleanup] readdir failed user=${userId} dir=${dir} err=${e.message}`);
    return;
  }

  for (const entry of entries) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) {
      await sweepDir(full, cutoff, result, userId);
      // remove the directory if it became empty after the sweep
      try {
        const remaining = await fs.readdir(full);
        if (remaining.length === 0) await fs.rmdir(full);
      } catch (err) {
        const e = err as NodeJS.ErrnoException;
        if (e.code !== 'ENOENT') {
          logger.warn(`[trash-cleanup] rmdir failed user=${userId} dir=${full} err=${e.message}`);
        }
      }
      continue;
    }
    if (!entry.isFile() && !entry.isSymbolicLink()) continue;

    try {
      const fileStat = await fs.lstat(full);
      if (fileStat.mtimeMs > cutoff) continue;
      const size = fileStat.size;
      await fs.unlink(full);
      result.deletedFiles++;
      result.freedBytes += size;
    } catch (err) {
      const e = err as NodeJS.ErrnoException;
      if (e.code === 'ENOENT') continue; // already gone
      logger.warn(`[trash-cleanup] unlink failed user=${userId} path=${full} err=${e.message}`);
    }
  }
}

export interface StartTrashCleanupOptions {
  userFolderRoot: string;
  retentionDays: number;
  intervalMs?: number; // override for tests
}

/**
 * Run one sweep at boot, then schedule a daily sweep. Returns a stop()
 * function and an `initialSweep` promise that tests can await to guarantee
 * the boot sweep has completed. The interval is unref()'d so it does not
 * block process exit.
 */
export function startTrashCleanup(opts: StartTrashCleanupOptions): {
  stop: () => void;
  initialSweep: Promise<void>;
} {
  const intervalMs = opts.intervalMs ?? SWEEP_INTERVAL_MS;
  logger.info(`[trash-cleanup] starting root=${opts.userFolderRoot} retentionDays=${opts.retentionDays} intervalMs=${intervalMs}`);

  const sweep = (): Promise<void> =>
    runTrashCleanup(opts.userFolderRoot, opts.retentionDays)
      .then((res) => {
        if (res.deletedFiles > 0) {
          logger.info(`[trash-cleanup] swept users=${res.scannedUsers} deleted=${res.deletedFiles} freedBytes=${res.freedBytes}`);
        } else {
          logger.info(`[trash-cleanup] swept users=${res.scannedUsers} deleted=0`);
        }
      })
      .catch((err: Error) => {
        logger.warn(`[trash-cleanup] sweep failed err=${err.message}`);
      });

  const initialSweep = sweep();
  const handle = setInterval(() => { void sweep(); }, intervalMs);
  handle.unref();

  return {
    stop: () => clearInterval(handle),
    initialSweep,
  };
}
