// Reclaims disk for transfers that are past their expiry or soft-deleted.
//
// Nothing previously removed payloads from UPLOAD_DIR, so every transfer ever
// sent stayed on disk indefinitely regardless of expiresAt.

import { promises as fs } from 'fs'
import path from 'path'
import { prisma } from '@/lib/prisma'
import { UPLOAD_DIR } from '@/lib/config'
import { TransferStatus } from '@prisma/client'

export interface CleanupReport {
  markedExpired: number
  filesDeleted: number
  bytesReclaimed: number
  staleTempDirs: number
  errors: string[]
}

/** Abandoned chunk directories older than this are removed. */
const TEMP_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000

/**
 * Refuse to unlink anything that does not live under UPLOAD_DIR. Paths come
 * from the database, so this is defence against a corrupted or tampered row
 * turning cleanup into arbitrary file deletion.
 */
function isInsideUploadDir(target: string): boolean {
  const relative = path.relative(UPLOAD_DIR, path.resolve(target))
  return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
}

export async function runCleanup(): Promise<CleanupReport> {
  const report: CleanupReport = {
    markedExpired: 0,
    filesDeleted: 0,
    bytesReclaimed: 0,
    staleTempDirs: 0,
    errors: [],
  }

  const now = new Date()

  // 1. Flip lapsed ACTIVE transfers to EXPIRED.
  const marked = await prisma.transfer.updateMany({
    where: { status: TransferStatus.ACTIVE, expiresAt: { lte: now } },
    data: { status: TransferStatus.EXPIRED },
  })
  report.markedExpired = marked.count

  // 2. Delete payloads for transfers that are no longer downloadable. The rows
  //    are kept so senders retain their history; only the bytes go.
  const reclaimable = await prisma.transfer.findMany({
    where: { status: { in: [TransferStatus.EXPIRED, TransferStatus.DELETED] } },
    include: { files: true },
  })

  for (const transfer of reclaimable) {
    for (const file of transfer.files) {
      if (!file.path) continue

      if (!isInsideUploadDir(file.path)) {
        report.errors.push(`Refusing to delete path outside upload dir: ${file.path}`)
        continue
      }

      try {
        const stat = await fs.stat(file.path)
        await fs.unlink(file.path)
        report.filesDeleted += 1
        report.bytesReclaimed += stat.size
      } catch (err: unknown) {
        const code = (err as NodeJS.ErrnoException)?.code
        // Already gone is the expected steady state on repeat runs.
        if (code !== 'ENOENT') {
          report.errors.push(`${file.path}: ${(err as Error).message}`)
        }
      }
    }
  }

  // 3. Drop abandoned chunk directories from uploads that never finished.
  const tempRoot = path.join(UPLOAD_DIR, '.tmp')
  try {
    const entries = await fs.readdir(tempRoot, { withFileTypes: true })
    for (const entry of entries) {
      if (!entry.isDirectory()) continue
      const dir = path.join(tempRoot, entry.name)
      try {
        const stat = await fs.stat(dir)
        if (now.getTime() - stat.mtimeMs > TEMP_DIR_MAX_AGE_MS) {
          await fs.rm(dir, { recursive: true, force: true })
          report.staleTempDirs += 1
        }
      } catch (err: unknown) {
        report.errors.push(`${dir}: ${(err as Error).message}`)
      }
    }
  } catch (err: unknown) {
    if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
      report.errors.push(`temp sweep: ${(err as Error).message}`)
    }
  }

  return report
}
