// src/engine/reflection/retention.ts
//
// Snapshot retention sweep for data/users/{userId}/.reflection-history/.
//
// Three exports:
//   pruneOldSnapshots  — delete snapshot dirs older than retentionDays
//   enforceDiskCap     — prune oldest snapshots first when total > capBytes
//   runReflectionRetentionSweep — iterate every user and apply both policies
//
// All mutations run inside withUserLock for the target user.
// index.jsonl is append-only — do NOT touch it on prune; just delete the dir.

import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { logger } from '../../logger.js';
import { withUserLock } from './user-lock.js';
import type { ReflectionConfig } from '../../config.js';

const DAY_MS = 86_400_000;

// ── Deps type (injectable for tests) ─────────────────────────────────────────

export interface RetentionDeps {
  /** Root of the user data directory (data/users lives here). */
  dataDir: string;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function historyDir(dataDir: string, userId: string): string {
  return join(dataDir, userId, '.reflection-history');
}

/**
 * Snapshot directories are named `{ts}-{jobId}` where `ts` is
 * `YYYYMMDDTHHmmssZ` (16 chars).  Parse the leading 16-char UTC timestamp
 * into a Date.  Returns null when the name cannot be parsed.
 */
function parseDirTs(name: string): Date | null {
  // e.g. "20260511T102300Z-j-001"
  const raw = name.slice(0, 16); // "20260511T102300Z"
  if (!/^\d{8}T\d{6}Z$/.test(raw)) return null;
  const iso =
    `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` +
    `T${raw.slice(9, 11)}:${raw.slice(11, 13)}:${raw.slice(13, 15)}Z`;
  const d = new Date(iso);
  return isNaN(d.getTime()) ? null : d;
}

/** Compute total bytes of all files (recursively) under `dir`. */
async function dirBytes(dir: string): Promise<number> {
  let total = 0;
  let entries;
  try {
    entries = await fs.readdir(dir, { withFileTypes: true });
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    if (e.code === 'ENOENT') return 0;
    throw err;
  }
  for (const entry of entries) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) {
      total += await dirBytes(full);
    } else if (entry.isFile() || entry.isSymbolicLink()) {
      try {
        const st = await fs.lstat(full);
        total += st.size;
      } catch {
        // ignore — file may have vanished
      }
    }
  }
  return total;
}

/** Remove a snapshot directory tree (best-effort). */
async function removeSnapshotDir(dir: string, userId: string): Promise<void> {
  try {
    await fs.rm(dir, { recursive: true, force: true });
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    logger.warn(
      `[reflection-retention] rm failed user=${userId} dir=${dir} err=${e.message}`,
    );
  }
}

// ── Public API ────────────────────────────────────────────────────────────────

export interface PruneResult {
  pruned: string[]; // list of snapshotIds removed
}

/**
 * Delete any snapshot directory whose timestamp is older than `retentionDays`.
 * Must be called inside a withUserLock critical section OR will acquire one
 * if called directly (the lock is acquired here for safety).
 */
export async function pruneOldSnapshots(
  deps: RetentionDeps,
  userId: string,
  retentionDays: number,
): Promise<PruneResult> {
  return withUserLock(deps.dataDir, userId, async () => {
    return _pruneOldSnapshotsLocked(deps, userId, retentionDays);
  });
}

async function _pruneOldSnapshotsLocked(
  deps: RetentionDeps,
  userId: string,
  retentionDays: number,
): Promise<PruneResult> {
  const hDir = historyDir(deps.dataDir, userId);
  const pruned: string[] = [];

  let entries;
  try {
    entries = await fs.readdir(hDir, { withFileTypes: true });
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    if (e.code === 'ENOENT') return { pruned };
    throw err;
  }

  const cutoffMs = Date.now() - retentionDays * DAY_MS;

  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    const ts = parseDirTs(entry.name);
    if (!ts) continue; // skip non-snapshot dirs (e.g. index.jsonl parent is not a dir)
    if (ts.getTime() > cutoffMs) continue; // still within retention window

    const full = join(hDir, entry.name);
    await removeSnapshotDir(full, userId);
    pruned.push(entry.name);
    logger.info(
      `[reflection-retention] pruned user=${userId} snapshotId=${entry.name} reason=age`,
    );
  }

  return { pruned };
}

/**
 * If the total bytes stored under `.reflection-history/` exceed `capBytes`,
 * prune the OLDEST snapshot directories first until usage falls under cap.
 */
export async function enforceDiskCap(
  deps: RetentionDeps,
  userId: string,
  capBytes: number,
): Promise<PruneResult> {
  return withUserLock(deps.dataDir, userId, async () => {
    return _enforceDiskCapLocked(deps, userId, capBytes);
  });
}

async function _enforceDiskCapLocked(
  deps: RetentionDeps,
  userId: string,
  capBytes: number,
): Promise<PruneResult> {
  const hDir = historyDir(deps.dataDir, userId);
  const pruned: string[] = [];

  let entries;
  try {
    entries = await fs.readdir(hDir, { withFileTypes: true });
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    if (e.code === 'ENOENT') return { pruned };
    throw err;
  }

  // Collect snapshot dirs only (must parse as a valid snapshot timestamp)
  const snapDirs: Array<{ name: string; ts: Date; full: string }> = [];
  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    const ts = parseDirTs(entry.name);
    if (!ts) continue;
    snapDirs.push({ name: entry.name, ts, full: join(hDir, entry.name) });
  }

  if (snapDirs.length === 0) return { pruned };

  // Compute current total bytes for the whole history dir
  let totalBytes = await dirBytes(hDir);
  if (totalBytes <= capBytes) return { pruned };

  // Sort oldest-first so we prune oldest first
  snapDirs.sort((a, b) => a.ts.getTime() - b.ts.getTime());

  for (const snap of snapDirs) {
    if (totalBytes <= capBytes) break;
    const snapBytes = await dirBytes(snap.full);
    await removeSnapshotDir(snap.full, userId);
    totalBytes -= snapBytes;
    pruned.push(snap.name);
    logger.info(
      `[reflection-retention] pruned user=${userId} snapshotId=${snap.name} reason=disk_cap` +
      ` removedBytes=${snapBytes} totalBytesAfter=${totalBytes}`,
    );
  }

  return { pruned };
}

/**
 * Iterate every user under `dataDir` and apply both retention policies.
 * Runs as the daily sweep (wired alongside trash-cleanup in server.ts).
 */
export async function runReflectionRetentionSweep(
  deps: RetentionDeps,
  config: Pick<ReflectionConfig, 'snapshotRetentionDays' | 'snapshotMaxBytesPerUser'>,
): Promise<void> {
  const { snapshotRetentionDays, snapshotMaxBytesPerUser } = config;

  let userEntries;
  try {
    userEntries = await fs.readdir(deps.dataDir, { withFileTypes: true });
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    if (e.code === 'ENOENT') return; // data/users doesn't exist yet
    logger.warn(`[reflection-retention] readdir failed err=${e.message}`);
    return;
  }

  for (const entry of userEntries) {
    if (!entry.isDirectory()) continue;
    const userId = entry.name;

    // Check if the user even has a .reflection-history directory
    const hDir = historyDir(deps.dataDir, userId);
    try {
      await fs.access(hDir);
    } catch {
      continue; // no history dir → nothing to do
    }

    try {
      // Age-based prune
      const ageResult = await pruneOldSnapshots(deps, userId, snapshotRetentionDays);
      if (ageResult.pruned.length > 0) {
        logger.info(
          `[reflection-retention] user=${userId} pruned=${ageResult.pruned.length} reason=age`,
        );
      }

      // Disk cap prune
      const capResult = await enforceDiskCap(deps, userId, snapshotMaxBytesPerUser);
      if (capResult.pruned.length > 0) {
        logger.info(
          `[reflection-retention] user=${userId} pruned=${capResult.pruned.length} reason=disk_cap`,
        );
      }

      const totalPruned = ageResult.pruned.length + capResult.pruned.length;
      if (totalPruned === 0) {
        logger.debug(`[reflection-retention] user=${userId} pruned=0 (clean)`);
      }
    } catch (err) {
      const e = err as Error;
      logger.warn(`[reflection-retention] sweep failed user=${userId} err=${e.message}`);
    }
  }
}

// ── Periodic sweep wiring (mirrors trash-cleanup pattern) ─────────────────────

const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;

export interface StartReflectionRetentionOptions {
  dataDir: string;
  config: Pick<ReflectionConfig, 'snapshotRetentionDays' | 'snapshotMaxBytesPerUser'>;
  intervalMs?: number; // override for tests
}

/**
 * Run one sweep at boot, then schedule a daily sweep.  Returns a stop()
 * function and an `initialSweep` promise (same API as startTrashCleanup).
 * The interval is unref()'d so it does not block process exit.
 */
export function startReflectionRetentionSweep(opts: StartReflectionRetentionOptions): {
  stop: () => void;
  initialSweep: Promise<void>;
} {
  const intervalMs = opts.intervalMs ?? SWEEP_INTERVAL_MS;
  const deps: RetentionDeps = { dataDir: opts.dataDir };

  logger.info(
    `[reflection-retention] starting dataDir=${opts.dataDir}` +
    ` retentionDays=${opts.config.snapshotRetentionDays}` +
    ` capBytes=${opts.config.snapshotMaxBytesPerUser}` +
    ` intervalMs=${intervalMs}`,
  );

  const sweep = (): Promise<void> =>
    runReflectionRetentionSweep(deps, opts.config).catch((err: Error) => {
      logger.warn(`[reflection-retention] sweep failed err=${err.message}`);
    });

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

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