import { createHash, randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import {
  copyFile,
  mkdir,
  readdir,
  readFile,
  rename,
  rm,
  stat,
  writeFile,
} from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { resolveDimcodeStateDir } from '../core/storage-paths'

export const BLOB_CACHE_DIR = 'blob-cache'
export const BLOB_DIR = 'blobs'
export const ATTACHMENTS_DIR = 'attachments'
export const META_DIR = 'meta'
export const BLOB_TTL_MS = 24 * 60 * 60 * 1000
const CLEANUP_MIN_INTERVAL_MS = 60 * 60 * 1000
const LEGACY_BLOB_CACHE_DIR = 'image-cache'
export const FILE_ID_PATTERN = /^[\w.-]{1,128}$/
const HOME_SESSION_CACHE_ID = '_home'

const IMAGE_EXTENSIONS = new Set([
  'png',
  'jpg',
  'jpeg',
  'gif',
  'svg',
  'webp',
  'bmp',
  'ico',
])

const TEXT_LIKE_EXTENSIONS = new Set([
  'txt',
  'text',
  'md',
  'markdown',
  'mdx',
  'rst',
  'log',
  'json',
  'jsonl',
  'json5',
  'yaml',
  'yml',
  'toml',
  'ini',
  'cfg',
  'conf',
  'properties',
  'env',
  'xml',
  'csv',
  'tsv',
  'html',
  'htm',
  'css',
  'scss',
  'less',
  'sass',
  'js',
  'jsx',
  'ts',
  'tsx',
  'mjs',
  'cjs',
  'mts',
  'cts',
  'vue',
  'svelte',
  'astro',
  'py',
  'rb',
  'php',
  'go',
  'rs',
  'java',
  'kt',
  'swift',
  'c',
  'cc',
  'cpp',
  'cxx',
  'h',
  'hh',
  'hpp',
  'hxx',
  'm',
  'mm',
  'cs',
  'sh',
  'bash',
  'zsh',
  'fish',
  'ps1',
  'sql',
  'graphql',
  'gql',
  'proto',
  'diff',
  'patch',
  'gitignore',
  'gitattributes',
  'editorconfig',
  'dockerfile',
  'makefile',
  'mk',
  'lock',
])

const EXT_MIME: Record<string, string> = {
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.webp': 'image/webp',
  '.bmp': 'image/bmp',
  '.ico': 'image/x-icon',
  '.svg': 'image/svg+xml',
  '.mp4': 'video/mp4',
  '.mov': 'video/quicktime',
  '.m4v': 'video/x-m4v',
  '.webm': 'video/webm',
  '.avi': 'video/x-msvideo',
  '.mkv': 'video/x-matroska',
  '.mpeg': 'video/mpeg',
  '.mpg': 'video/mpeg',
  '.wmv': 'video/x-ms-wmv',
  '.3gp': 'video/3gpp',
  '.mp3': 'audio/mpeg',
  '.wav': 'audio/wav',
  '.ogg': 'audio/ogg',
  '.flac': 'audio/flac',
  '.m4a': 'audio/mp4',
  '.pdf': 'application/pdf',
  '.txt': 'text/plain',
  '.md': 'text/markdown',
  '.json': 'application/json',
  '.yaml': 'text/yaml',
  '.yml': 'text/yaml',
  '.toml': 'text/plain',
  '.ini': 'text/plain',
  '.xml': 'application/xml',
  '.csv': 'text/csv',
}

let lastCleanupAt = 0

export type SessionBlobWriteResult = Readonly<{
  fileId: string
  filePath: string
  lineCount: number
  sha256: string
  byteSize: number
}>

export type SessionAttachmentWriteResult = Readonly<{
  fileId: string
  filePath: string
  byteSize: number
  mediaType: string
  originalName: string
}>

export type BlobLogger = (event: string, payload?: unknown) => void

type SessionAttachmentPathInfo = Readonly<{
  sessionId: string
  fileName: string
  legacy: boolean
}>

type AttachmentMetaMatch = Readonly<{
  fileId: string
  metaPath: string
  meta: Record<string, unknown>
}>

export function resolveStateDir(): string {
  return resolveDimcodeStateDir(process.env as Record<string, string | undefined>)
}

export function resolveSessionCacheDir(sessionId: string): string {
  return resolveSessionCacheDirForRoot(sessionId, BLOB_CACHE_DIR)
}

export function resolveSessionAttachmentsDir(sessionId: string): string {
  return path.resolve(resolveSessionCacheDir(sessionId), ATTACHMENTS_DIR)
}

function resolveLegacySessionCacheDir(sessionId: string): string {
  return resolveSessionCacheDirForRoot(sessionId, LEGACY_BLOB_CACHE_DIR)
}

export function resolveBlobPathFromFileId(
  fileId: string,
  sessionId: string,
): string {
  if (!FILE_ID_PATTERN.test(fileId))
    throw new Error('file_id must match /^[\\w.-]{1,128}$/')

  const candidates = resolveBlobPathCandidates(sessionId, fileId)
  return findExistingPath(candidates) ?? candidates[0]!
}

export function normalizeSessionId(raw: unknown): string | null {
  const value = String(raw ?? '').trim()
  if (!value)
    return null
  if (value === '.' || value === '..')
    return null
  if (value.includes('/') || value.includes('\\'))
    return null
  if (value.includes('\0'))
    return null
  return value
}

function getFileExtension(filePath: string): string {
  const ext = path.extname(filePath || '').toLowerCase()
  if (!ext)
    return ''
  return ext.startsWith('.') ? ext.slice(1) : ext
}

function basenameFromPath(filePath: string): string {
  return path.basename(String(filePath ?? '').trim())
}

function isLegacyAttachmentPath(normalizedPath: string): boolean {
  if (!normalizedPath.includes(`/${LEGACY_BLOB_CACHE_DIR}/`))
    return false
  return !normalizedPath.includes(`/${BLOB_DIR}/`) && !normalizedPath.includes(`/${META_DIR}/`)
}

function parseAttachmentPathInfo(absPath: string): SessionAttachmentPathInfo | null {
  const normalized = normalizeMaybePath(absPath)
  const blobMatch = normalized.match(/\/blob-cache\/([^/]+)\/attachments\/([^/]+)$/)
  if (blobMatch) {
    const sessionId = normalizeSessionId(blobMatch[1] ?? '')
    const fileName = basenameFromPath(blobMatch[2] ?? '')
    if (sessionId && fileName)
      return { sessionId, fileName, legacy: false }
  }

  const legacyMatch = normalized.match(/\/image-cache\/([^/]+)\/([^/]+)$/)
  if (!legacyMatch || !isLegacyAttachmentPath(normalized))
    return null
  const sessionId = normalizeSessionId(legacyMatch[1] ?? '')
  const fileName = basenameFromPath(legacyMatch[2] ?? '')
  if (!sessionId || !fileName)
    return null
  return { sessionId, fileName, legacy: true }
}

export function isTextLikeFilePath(filePath: string): boolean {
  const ext = getFileExtension(filePath)
  return Boolean(ext && TEXT_LIKE_EXTENSIONS.has(ext))
}

export function shouldCacheFileToSessionAttachment(filePath: string): boolean {
  return !isTextLikeFilePath(filePath)
}

export function isSessionCachedImagePath(absPath: string): boolean {
  const info = parseAttachmentPathInfo(absPath)
  if (!info)
    return false
  const ext = getFileExtension(info.fileName)
  return Boolean(ext && IMAGE_EXTENSIONS.has(ext))
}

export function isHomeSessionAttachmentPath(absPath: string): boolean {
  const info = parseAttachmentPathInfo(absPath)
  return Boolean(info && info.sessionId === HOME_SESSION_CACHE_ID)
}

export function guessMimeTypeFromPath(filePath: string): string {
  const ext = path.extname(filePath).toLowerCase()
  return EXT_MIME[ext] || 'application/octet-stream'
}

function isWithin(candidate: string, allowed: string): boolean {
  return candidate === allowed || candidate.startsWith(allowed + path.sep)
}

function resolveSessionCacheDirForRoot(
  sessionId: string,
  rootDir: string,
): string {
  return path.resolve(resolveStateDir(), rootDir, sessionId)
}

function resolveBlobPathInSessionDir(sessionDir: string, fileId: string): string {
  const blobsRoot = path.resolve(sessionDir, BLOB_DIR)
  const blobPath = path.resolve(blobsRoot, `${fileId}.txt`)
  if (!isWithin(blobPath, blobsRoot))
    throw new Error('Invalid file_id path')
  return blobPath
}

function resolveMetaPathInSessionDir(sessionDir: string, fileId: string): string {
  const metaRoot = path.resolve(sessionDir, META_DIR)
  const metaPath = path.resolve(metaRoot, `${fileId}.json`)
  if (!isWithin(metaPath, metaRoot))
    throw new Error('Invalid file_id path')
  return metaPath
}

function resolveBlobPathCandidates(
  sessionId: string,
  fileId: string,
): readonly string[] {
  return [
    resolveBlobPathInSessionDir(resolveSessionCacheDir(sessionId), fileId),
    resolveBlobPathInSessionDir(resolveLegacySessionCacheDir(sessionId), fileId),
  ]
}

function resolveMetaPathCandidates(
  sessionId: string,
  fileId: string,
): readonly string[] {
  return [
    resolveMetaPathInSessionDir(resolveSessionCacheDir(sessionId), fileId),
    resolveMetaPathInSessionDir(resolveLegacySessionCacheDir(sessionId), fileId),
  ]
}

function findExistingPath(paths: readonly string[]): string | null {
  for (const candidate of paths) {
    if (existsSync(candidate))
      return candidate
  }
  return null
}

function countLines(text: string): number {
  if (!text)
    return 0
  return text.split('\n').length
}

function normalizeMaybePath(raw: unknown): string {
  return String(raw ?? '').replace(/\\/g, '/')
}

export function parseBlobPathInfo(
  absPath: string,
): { sessionId: string, fileId: string } | null {
  const normalized = normalizeMaybePath(absPath)
  const match = normalized.match(
    /\/(?:blob-cache|image-cache)\/([^/]+)\/blobs\/([\w.-]+)\.txt$/,
  )
  if (!match)
    return null
  const sessionId = normalizeSessionId(match[1] ?? '')
  const fileId = String(match[2] ?? '').trim()
  if (!sessionId || !FILE_ID_PATTERN.test(fileId))
    return null
  return { sessionId, fileId }
}

async function ensureBlobDirs(
  sessionDir: string,
): Promise<{ blobsDir: string, metaDir: string } | null> {
  const blobsDir = path.join(sessionDir, BLOB_DIR)
  const metaDir = path.join(sessionDir, META_DIR)
  try {
    await mkdir(blobsDir, { recursive: true })
    await mkdir(metaDir, { recursive: true })
    return { blobsDir, metaDir }
  }
  catch {
    return null
  }
}

async function ensureAttachmentDirs(
  sessionDir: string,
): Promise<{ attachmentsDir: string, metaDir: string } | null> {
  const attachmentsDir = path.join(sessionDir, ATTACHMENTS_DIR)
  const metaDir = path.join(sessionDir, META_DIR)
  try {
    await mkdir(attachmentsDir, { recursive: true })
    await mkdir(metaDir, { recursive: true })
    return { attachmentsDir, metaDir }
  }
  catch {
    return null
  }
}

async function copyFileUnique(
  src: string,
  destDir: string,
  baseName: string,
): Promise<string | null> {
  const safeBaseName = basenameFromPath(baseName) || basenameFromPath(src) || 'attachment'
  const ext = path.extname(safeBaseName)
  const stem = ext ? safeBaseName.slice(0, -ext.length) : safeBaseName
  let candidate = path.join(destDir, safeBaseName)
  for (let i = 0; i < 40; i++) {
    try {
      await copyFile(src, candidate)
      const info = await stat(candidate)
      if (info.isFile() && info.size > 0)
        return candidate
    }
    catch {
      // try next suffix
    }
    const suffix = `-${i + 1}`
    candidate = path.join(destDir, `${stem}${suffix}${ext}`)
  }
  return null
}

async function readAttachmentMetaByPath(
  absPath: string,
): Promise<AttachmentMetaMatch | null> {
  const info = parseAttachmentPathInfo(absPath)
  if (!info || info.legacy)
    return null
  const metaDir = path.join(resolveSessionCacheDir(info.sessionId), META_DIR)
  let entries: string[] = []
  try {
    entries = await readdir(metaDir)
  }
  catch {
    return null
  }

  for (const entry of entries) {
    if (!entry.endsWith('.json'))
      continue
    const metaPath = path.join(metaDir, entry)
    try {
      const raw = await readFile(metaPath, 'utf8')
      const meta = JSON.parse(raw)
      if (!meta || typeof meta !== 'object')
        continue
      if (String((meta as any).kind ?? '') !== 'attachment')
        continue
      if (String((meta as any).stored_name ?? '') !== info.fileName)
        continue
      const fileId = String((meta as any).file_id ?? '').trim()
      if (!FILE_ID_PATTERN.test(fileId))
        continue
      return { fileId, metaPath, meta: meta as Record<string, unknown> }
    }
    catch {
      // ignore invalid meta
    }
  }
  return null
}

export async function writeSessionBlob(
  opts: Readonly<{
    content: string
    sessionId: string
    source?: string
    logger?: BlobLogger
  }>,
): Promise<SessionBlobWriteResult | null> {
  const sessionDir = resolveSessionCacheDir(opts.sessionId)
  const dirs = await ensureBlobDirs(sessionDir)
  if (!dirs)
    return null

  const fileId = `blob-${randomUUID().replace(/-/g, '')}`
  const blobPath = path.join(dirs.blobsDir, `${fileId}.txt`)
  const metaPath = path.join(dirs.metaDir, `${fileId}.json`)
  const lineCount = countLines(opts.content)
  const sha256 = createHash('sha256').update(opts.content).digest('hex')
  const byteSize = Buffer.byteLength(opts.content, 'utf-8')
  const nowIso = new Date().toISOString()

  try {
    await writeFile(blobPath, opts.content, 'utf8')
    await writeFile(
      metaPath,
      JSON.stringify(
        {
          file_id: fileId,
          kind: 'text',
          sha256,
          line_count: lineCount,
          byte_size: byteSize,
          created_at: nowIso,
          source: String(opts.source ?? 'unknown'),
        },
        null,
        2,
      ),
      'utf8',
    )
    return { fileId, filePath: blobPath, lineCount, sha256, byteSize }
  }
  catch {
    return null
  }
}

export async function cacheTextToSessionBlob(
  opts: Readonly<{
    content: string
    sessionId?: string
    source?: string
    logger?: BlobLogger
  }>,
): Promise<SessionBlobWriteResult | null> {
  const content = String(opts.content ?? '')
  if (!content)
    return null
  const sessionId = normalizeSessionId(opts.sessionId)
  if (!sessionId)
    return null

  await cleanupExpiredSessionCaches({
    ttlMs: BLOB_TTL_MS,
    logger: opts.logger,
  }).catch(() => {})

  const written = await writeSessionBlob({
    content,
    sessionId,
    source: opts.source,
    logger: opts.logger,
  })
  if (!written)
    return null

  opts.logger?.('session_blob_cached', {
    sessionId,
    fileId: written.fileId,
    lineCount: written.lineCount,
    byteSize: written.byteSize,
  })
  return written
}

export async function cacheFileToSessionAttachment(
  opts: Readonly<{
    sourcePath: string
    sessionId?: string
    source?: string
    preferredBaseName?: string
    mediaType?: string
    logger?: BlobLogger
  }>,
): Promise<SessionAttachmentWriteResult | null> {
  const cleanedPath = String(opts.sourcePath ?? '').trim()
  if (!cleanedPath)
    return null
  const sessionId = normalizeSessionId(opts.sessionId)
  if (!sessionId)
    return null

  let sourceInfo
  try {
    sourceInfo = await stat(cleanedPath)
  }
  catch {
    return null
  }
  if (!sourceInfo.isFile() || sourceInfo.size <= 0)
    return null

  await cleanupExpiredSessionCaches({
    ttlMs: BLOB_TTL_MS,
    logger: opts.logger,
  }).catch(() => {})

  const sessionDir = resolveSessionCacheDir(sessionId)
  const dirs = await ensureAttachmentDirs(sessionDir)
  if (!dirs)
    return null

  const fileId = `att-${randomUUID().replace(/-/g, '')}`
  const preferredBaseName = basenameFromPath(
    String(opts.preferredBaseName ?? '').trim() || basenameFromPath(cleanedPath),
  )
  const storedPath = await copyFileUnique(cleanedPath, dirs.attachmentsDir, preferredBaseName)
  if (!storedPath)
    return null

  const metaPath = path.join(dirs.metaDir, `${fileId}.json`)
  const mediaType = String(opts.mediaType ?? '').trim() || guessMimeTypeFromPath(storedPath)
  const originalName = basenameFromPath(cleanedPath)
  const storedName = basenameFromPath(storedPath)
  const nowIso = new Date().toISOString()

  try {
    await writeFile(
      metaPath,
      JSON.stringify(
        {
          file_id: fileId,
          kind: 'attachment',
          byte_size: sourceInfo.size,
          created_at: nowIso,
          source: String(opts.source ?? 'unknown'),
          media_type: mediaType,
          original_name: originalName,
          stored_name: storedName,
        },
        null,
        2,
      ),
      'utf8',
    )
    opts.logger?.('session_attachment_cached', {
      sessionId,
      fileId,
      byteSize: sourceInfo.size,
      mediaType,
      originalName,
      storedName,
    })
    return {
      fileId,
      filePath: storedPath,
      byteSize: sourceInfo.size,
      mediaType,
      originalName,
    }
  }
  catch {
    await rm(storedPath, { force: true }).catch(() => {})
    return null
  }
}

async function moveFileOrCopy(src: string, dest: string): Promise<boolean> {
  try {
    await rename(src, dest)
    return true
  }
  catch {
    try {
      await copyFile(src, dest)
      await rm(src, { force: true })
      return true
    }
    catch {
      return false
    }
  }
}

export async function deleteSessionBlobByPath(
  absPath: string,
  logger?: BlobLogger,
): Promise<boolean> {
  const info = parseBlobPathInfo(absPath)
  if (!info)
    return false
  try {
    await Promise.all([
      ...resolveBlobPathCandidates(info.sessionId, info.fileId)
        .map(candidate => rm(candidate, { force: true })),
      ...resolveMetaPathCandidates(info.sessionId, info.fileId)
        .map(candidate => rm(candidate, { force: true })),
    ])
    await Promise.all([
      removeDirIfEmpty(path.join(resolveSessionCacheDir(info.sessionId), BLOB_DIR)),
      removeDirIfEmpty(path.join(resolveSessionCacheDir(info.sessionId), META_DIR)),
      removeDirIfEmpty(resolveSessionCacheDir(info.sessionId)),
      removeDirIfEmpty(path.join(resolveLegacySessionCacheDir(info.sessionId), BLOB_DIR)),
      removeDirIfEmpty(path.join(resolveLegacySessionCacheDir(info.sessionId), META_DIR)),
      removeDirIfEmpty(resolveLegacySessionCacheDir(info.sessionId)),
    ])
    logger?.('session_blob_deleted', {
      sessionId: info.sessionId,
      fileId: info.fileId,
    })
    return true
  }
  catch {
    return false
  }
}

export async function relocateSessionBlobPath(
  absPath: string,
  targetSessionId: string,
): Promise<string | null> {
  const sourceInfo = parseBlobPathInfo(absPath)
  const targetSid = normalizeSessionId(targetSessionId)
  if (!sourceInfo || !targetSid)
    return null
  if (sourceInfo.sessionId === targetSid)
    return absPath

  const targetSessionDir = resolveSessionCacheDir(targetSid)
  const targetDirs = await ensureBlobDirs(targetSessionDir)
  if (!targetDirs)
    return null

  const sourceBlobPath = findExistingPath(
    resolveBlobPathCandidates(sourceInfo.sessionId, sourceInfo.fileId),
  )
  if (!sourceBlobPath)
    return null
  const sourceMetaPath = findExistingPath(
    resolveMetaPathCandidates(sourceInfo.sessionId, sourceInfo.fileId),
  )
  const targetBlobPath = path.join(
    targetDirs.blobsDir,
    `${sourceInfo.fileId}.txt`,
  )
  const targetMetaPath = path.join(
    targetDirs.metaDir,
    `${sourceInfo.fileId}.json`,
  )

  const movedBlob = await moveFileOrCopy(sourceBlobPath, targetBlobPath)
  if (!movedBlob)
    return null
  if (sourceMetaPath)
    void moveFileOrCopy(sourceMetaPath, targetMetaPath)
  await Promise.all([
    removeDirIfEmpty(path.join(resolveLegacySessionCacheDir(sourceInfo.sessionId), BLOB_DIR)),
    removeDirIfEmpty(path.join(resolveLegacySessionCacheDir(sourceInfo.sessionId), META_DIR)),
    removeDirIfEmpty(resolveLegacySessionCacheDir(sourceInfo.sessionId)),
    removeDirIfEmpty(path.join(resolveSessionCacheDir(sourceInfo.sessionId), BLOB_DIR)),
    removeDirIfEmpty(path.join(resolveSessionCacheDir(sourceInfo.sessionId), META_DIR)),
    removeDirIfEmpty(resolveSessionCacheDir(sourceInfo.sessionId)),
  ])
  return targetBlobPath
}

async function deleteSessionAttachmentByPath(
  absPath: string,
  logger?: BlobLogger,
): Promise<boolean> {
  const info = parseAttachmentPathInfo(absPath)
  if (!info)
    return false
  try {
    await rm(absPath, { force: true })
    const metaMatch = await readAttachmentMetaByPath(absPath)
    if (metaMatch)
      await rm(metaMatch.metaPath, { force: true })
    await Promise.all([
      removeDirIfEmpty(path.join(resolveSessionCacheDir(info.sessionId), ATTACHMENTS_DIR)),
      removeDirIfEmpty(path.join(resolveSessionCacheDir(info.sessionId), META_DIR)),
      removeDirIfEmpty(resolveSessionCacheDir(info.sessionId)),
      removeDirIfEmpty(resolveLegacySessionCacheDir(info.sessionId)),
    ])
    logger?.('session_attachment_deleted', {
      sessionId: info.sessionId,
      fileName: info.fileName,
    })
    return true
  }
  catch {
    return false
  }
}

export async function relocateSessionAttachmentPath(
  absPath: string,
  targetSessionId: string,
  logger?: BlobLogger,
): Promise<string | null> {
  const sourceInfo = parseAttachmentPathInfo(absPath)
  const targetSid = normalizeSessionId(targetSessionId)
  if (!sourceInfo || !targetSid)
    return null
  if (!sourceInfo.legacy && sourceInfo.sessionId === targetSid)
    return absPath

  const metaMatch = await readAttachmentMetaByPath(absPath)
  const preferredBaseName = String(
    metaMatch?.meta.stored_name ?? basenameFromPath(absPath),
  ).trim() || basenameFromPath(absPath)
  const mediaType = String(metaMatch?.meta.media_type ?? '').trim() || guessMimeTypeFromPath(absPath)
  const source = String(metaMatch?.meta.source ?? 'attachment_relocate').trim() || 'attachment_relocate'
  const cached = await cacheFileToSessionAttachment({
    sourcePath: absPath,
    sessionId: targetSid,
    source,
    preferredBaseName,
    mediaType,
    logger,
  })
  if (!cached)
    return null

  await deleteSessionAttachmentByPath(absPath, logger).catch(() => false)
  return cached.filePath
}

export async function readBlobMeta(
  sessionId: string,
  fileId: string,
): Promise<Record<string, unknown> | null> {
  const sid = normalizeSessionId(sessionId)
  const fid = String(fileId ?? '').trim()
  if (!sid || !fid)
    return null
  for (const metaPath of resolveMetaPathCandidates(sid, fid)) {
    try {
      const raw = await readFile(metaPath, 'utf8')
      const parsed = JSON.parse(raw)
      if (!parsed || typeof parsed !== 'object')
        return null
      return parsed as Record<string, unknown>
    }
    catch {
      // try next candidate
    }
  }
  return null
}

export async function cleanupSessionCache(
  sessionId: string,
  logger?: BlobLogger,
): Promise<void> {
  const sid = normalizeSessionId(sessionId)
  if (!sid)
    return
  try {
    await rm(resolveSessionCacheDir(sid), { recursive: true, force: true })
    await rm(resolveLegacySessionCacheDir(sid), { recursive: true, force: true })
    logger?.('session_cache_deleted', { sessionId: sid })
  }
  catch {
    // ignore
  }
}

export async function cleanupExpiredSessionCaches(
  opts?: Readonly<{ ttlMs?: number, logger?: BlobLogger }>,
): Promise<void> {
  const now = Date.now()
  if (now - lastCleanupAt < CLEANUP_MIN_INTERVAL_MS)
    return
  lastCleanupAt = now

  const ttlMs = Math.max(1000, Math.floor(opts?.ttlMs ?? BLOB_TTL_MS))
  const cutoff = now - ttlMs
  await cleanupDedicatedCacheRoot(
    path.join(resolveStateDir(), BLOB_CACHE_DIR),
    cutoff,
    opts?.logger,
  )
  await cleanupLegacyBlobCacheRoot(
    path.join(resolveStateDir(), LEGACY_BLOB_CACHE_DIR),
    cutoff,
    opts?.logger,
  )
}

async function cleanupDedicatedCacheRoot(
  cacheRoot: string,
  cutoff: number,
  logger?: BlobLogger,
): Promise<void> {
  let entries: string[] = []
  try {
    entries = await readdir(cacheRoot)
  }
  catch {
    return
  }

  await Promise.all(
    entries.map(async (entry) => {
      const sid = normalizeSessionId(entry)
      if (!sid)
        return
      const sessionDir = path.join(cacheRoot, sid)
      const candidates = [
        sessionDir,
        path.join(sessionDir, BLOB_DIR),
        path.join(sessionDir, ATTACHMENTS_DIR),
        path.join(sessionDir, META_DIR),
      ]
      const mtimes: number[] = []
      for (const candidate of candidates) {
        try {
          const info = await stat(candidate)
          if (info.isDirectory() || info.isFile())
            mtimes.push(info.mtimeMs)
        }
        catch {
          // ignore missing candidate
        }
      }
      if (!mtimes.length)
        return
      if (Math.max(...mtimes) >= cutoff)
        return
      try {
        await rm(sessionDir, { recursive: true, force: true })
        logger?.('session_cache_ttl_deleted', {
          sessionId: sid,
          mtimeMs: Math.max(...mtimes),
          cutoff,
        })
      }
      catch {
        // ignore
      }
    }),
  )
}

async function cleanupLegacyBlobCacheRoot(
  cacheRoot: string,
  cutoff: number,
  logger?: BlobLogger,
): Promise<void> {
  let entries: string[] = []
  try {
    entries = await readdir(cacheRoot)
  }
  catch {
    return
  }

  await Promise.all(
    entries.map(async (entry) => {
      const sid = normalizeSessionId(entry)
      if (!sid)
        return
      const sessionDir = path.join(cacheRoot, sid)
      const blobDir = path.join(sessionDir, BLOB_DIR)
      const metaDir = path.join(sessionDir, META_DIR)
      const candidates = [blobDir, metaDir]
      const mtimes: number[] = []

      for (const candidate of candidates) {
        try {
          const info = await stat(candidate)
          if (info.isDirectory())
            mtimes.push(info.mtimeMs)
        }
        catch {
          // ignore missing candidate
        }
      }

      if (!mtimes.length)
        return
      if (Math.max(...mtimes) >= cutoff)
        return

      try {
        await rm(blobDir, { recursive: true, force: true })
        await rm(metaDir, { recursive: true, force: true })
        await removeDirIfEmpty(sessionDir)
        logger?.('session_cache_ttl_deleted', {
          sessionId: sid,
          mtimeMs: Math.max(...mtimes),
          cutoff,
        })
      }
      catch {
        // ignore
      }
    }),
  )
}

async function removeDirIfEmpty(dir: string): Promise<void> {
  try {
    const entries = await readdir(dir)
    if (entries.length === 0)
      await rm(dir, { recursive: true, force: true })
  }
  catch {
    // ignore
  }
}

export function _resetCleanupTimer(): void {
  lastCleanupAt = 0
}
