/**
 * Function/cache-cleaner.js
 * ---------------------------------------------------------------------------
 * CACHE & TEMPORARY FILE CLEANER
 *
 * Safety model
 * ---------------------------------------------------------------------------
 *  1. Only paths produced by the category table below are ever touched.
 *  2. Every resolved path must pass isPathAllowed(): it has to live inside a
 *     known cache/temp root and must not be a drive root, a user profile
 *     folder, a documents/downloads folder or a Windows system folder.
 *  3. The cleaner always SCANS first and shows counts + sizes; deletion only
 *     happens after an explicit confirmation (configurable).
 *  4. Files still in use are skipped, never force-unlocked.
 *  5. Directories are removed only when they are empty and only inside the
 *     scanned target (the target folder itself is always kept).
 *  6. Personal data (documents, downloads, saves, credentials, cookies,
 *     logins, bookmarks, history, configuration) is never in scope: the
 *     browser categories descend exclusively into cache sub-folders.
 * ---------------------------------------------------------------------------
 */

'use strict';

const fs = require('fs');
const fsp = fs.promises;
const os = require('os');
const path = require('path');

const system = require('../../core/system');
const paths = require('../../core/paths');
const logger = require('../../core/logger');

/* --------------------------------------------------------------------------
 * Environment roots
 * ------------------------------------------------------------------------ */

const ENV = {
  localAppData: process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
  roamingAppData: process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
  userTemp: process.env.TEMP || process.env.TMP || path.join(os.homedir(), 'AppData', 'Local', 'Temp'),
  windir: process.env.SystemRoot || process.env.windir || 'C:\\Windows',
  programData: process.env.ProgramData || 'C:\\ProgramData',
  userProfile: process.env.USERPROFILE || os.homedir(),
};

/** Roots inside which deletion is permitted at all. */
const ALLOWED_ROOTS = [
  ENV.userTemp,
  path.join(ENV.localAppData),
  path.join(ENV.roamingAppData),
  path.join(ENV.windir, 'Temp'),
  path.join(ENV.windir, 'Prefetch'),
  path.join(ENV.windir, 'SoftwareDistribution'),
  path.join(ENV.windir, 'ServiceProfiles'),
  path.join(ENV.programData, 'Microsoft', 'Windows', 'WER'),
].map((p) => path.resolve(p).toLowerCase());

/** Paths that must never be the deletion target, whatever a category says. */
const FORBIDDEN_EXACT = [
  ENV.userProfile,
  ENV.localAppData,
  ENV.roamingAppData,
  ENV.windir,
  ENV.programData,
  path.join(ENV.windir, 'System32'),
  path.join(ENV.windir, 'SysWOW64'),
  path.join(ENV.userProfile, 'Documents'),
  path.join(ENV.userProfile, 'Downloads'),
  path.join(ENV.userProfile, 'Desktop'),
  path.join(ENV.userProfile, 'Pictures'),
  path.join(ENV.userProfile, 'Videos'),
  path.join(ENV.userProfile, 'Music'),
  path.join(ENV.userProfile, 'Saved Games'),
  path.join(ENV.userProfile, 'OneDrive'),
  process.env.ProgramFiles || 'C:\\Program Files',
  process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)',
]
  .filter(Boolean)
  .map((p) => path.resolve(p).toLowerCase());

/** File names that are never deleted even inside an allowed folder. */
const PROTECTED_FILE_NAMES = [
  'desktop.ini',
  'ntuser.dat',
  'iconcache.db',
];

/** True when `child` is inside `parent`. */
function isInside(parent, child) {
  const rel = path.relative(path.resolve(parent), path.resolve(child));
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}

/**
 * Gatekeeper for every path the cleaner is about to touch.
 * @returns {{allowed:boolean, reason?:string}}
 */
function isPathAllowed(target) {
  let resolved;
  try {
    resolved = path.resolve(target);
  } catch {
    return { allowed: false, reason: 'unresolvable path' };
  }
  const lower = resolved.toLowerCase();

  if (path.parse(resolved).root.toLowerCase() === lower) {
    return { allowed: false, reason: 'drive root' };
  }
  if (FORBIDDEN_EXACT.includes(lower)) {
    return { allowed: false, reason: 'protected system or personal folder' };
  }
  if (!ALLOWED_ROOTS.some((root) => isInside(root, resolved))) {
    return { allowed: false, reason: 'outside the permitted cache roots' };
  }
  // Require a reasonable depth so something like "C:\Windows" can never match.
  if (resolved.split(path.sep).filter(Boolean).length < 3) {
    return { allowed: false, reason: 'path is too shallow' };
  }
  return { allowed: true };
}

/* --------------------------------------------------------------------------
 * Category table
 * ------------------------------------------------------------------------ */

/** Cache sub-folders inside a Chromium profile that are safe to clear. */
const CHROMIUM_CACHE_DIRS = [
  'Cache',
  'Code Cache',
  'GPUCache',
  'DawnCache',
  'DawnGraphiteCache',
  'DawnWebGPUCache',
  'GrShaderCache',
  'ShaderCache',
  path.join('Service Worker', 'CacheStorage'),
  path.join('Service Worker', 'ScriptCache'),
  'Media Cache',
];

const CHROMIUM_BROWSERS = [
  { name: 'Google Chrome', root: path.join(ENV.localAppData, 'Google', 'Chrome', 'User Data') },
  { name: 'Microsoft Edge', root: path.join(ENV.localAppData, 'Microsoft', 'Edge', 'User Data') },
  { name: 'Brave', root: path.join(ENV.localAppData, 'BraveSoftware', 'Brave-Browser', 'User Data') },
  { name: 'Vivaldi', root: path.join(ENV.localAppData, 'Vivaldi', 'User Data') },
  { name: 'Chromium', root: path.join(ENV.localAppData, 'Chromium', 'User Data') },
  { name: 'Yandex', root: path.join(ENV.localAppData, 'Yandex', 'YandexBrowser', 'User Data') },
  { name: 'Opera', root: path.join(ENV.roamingAppData, 'Opera Software', 'Opera Stable') },
  { name: 'Opera GX', root: path.join(ENV.roamingAppData, 'Opera Software', 'Opera GX Stable') },
];

function dirExists(p) {
  try {
    return fs.statSync(p).isDirectory();
  } catch {
    return false;
  }
}

/** Discover browser cache folders that actually exist on this machine. */
function discoverBrowserTargets() {
  const targets = [];

  for (const browser of CHROMIUM_BROWSERS) {
    if (!dirExists(browser.root)) continue;

    // Profile folders: Default, Profile 1, Profile 2, Guest Profile ...
    const profiles = ['.'];
    try {
      for (const entry of fs.readdirSync(browser.root, { withFileTypes: true })) {
        if (!entry.isDirectory()) continue;
        if (/^(Default|Profile \d+|Guest Profile|System Profile)$/i.test(entry.name)) {
          profiles.push(entry.name);
        }
      }
    } catch {
      /* unreadable profile list - fall back to the root only */
    }

    for (const profile of profiles) {
      for (const cacheDir of CHROMIUM_CACHE_DIRS) {
        const full = path.join(browser.root, profile, cacheDir);
        if (dirExists(full)) {
          targets.push({ path: full, label: `${browser.name} - ${profile === '.' ? 'shared' : profile} / ${cacheDir}` });
        }
      }
    }
  }

  // Firefox / Waterfox style profiles
  const firefoxRoots = [
    path.join(ENV.localAppData, 'Mozilla', 'Firefox', 'Profiles'),
    path.join(ENV.localAppData, 'Waterfox', 'Profiles'),
  ];
  for (const root of firefoxRoots) {
    if (!dirExists(root)) continue;
    try {
      for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
        if (!entry.isDirectory()) continue;
        for (const cacheDir of ['cache2', 'startupCache', 'shader-cache', 'thumbnails']) {
          const full = path.join(root, entry.name, cacheDir);
          if (dirExists(full)) targets.push({ path: full, label: `Firefox - ${entry.name} / ${cacheDir}` });
        }
      }
    } catch {
      /* ignore */
    }
  }

  return targets;
}

/**
 * Build the category list for this machine.
 * Each category: { id, name, description, admin, targets[], risk, enabled }
 * Target: { path, label?, pattern?, keepRoot? }
 */
function buildCategories(settings) {
  const includeAdmin = settings.cleaner.includeAdminCategories;
  const disabled = (settings.cleaner.disabledCategories || []).map((s) => String(s).toLowerCase());

  const categories = [
    {
      id: 'user-temp',
      name: 'User Temp Files',
      description: 'Temporary files created by applications for the current user (%TEMP%).',
      admin: false,
      risk: 'low',
      targets: [
        { path: ENV.userTemp },
        { path: path.join(ENV.localAppData, 'Temp') },
      ],
    },
    {
      id: 'windows-temp',
      name: 'Windows Temp Files',
      description: 'System-wide temporary files (%WINDIR%\\Temp). Files in use are skipped.',
      admin: true,
      risk: 'low',
      targets: [{ path: path.join(ENV.windir, 'Temp') }],
    },
    {
      id: 'inet-cache',
      name: 'Windows Internet Cache',
      description: 'WinINet / Internet Explorer temporary internet files.',
      admin: false,
      risk: 'low',
      targets: [
        { path: path.join(ENV.localAppData, 'Microsoft', 'Windows', 'INetCache') },
        { path: path.join(ENV.localAppData, 'Microsoft', 'Windows', 'INetCookies', 'Low') },
        { path: path.join(ENV.localAppData, 'Microsoft', 'Windows', 'Temporary Internet Files') },
      ],
    },
    {
      id: 'thumbnail-cache',
      name: 'Thumbnail & Icon Cache',
      description: 'Explorer thumbnail/icon databases. Windows rebuilds them automatically.',
      admin: false,
      risk: 'low',
      note: 'Explorer keeps these files open - some may be reported as in use.',
      targets: [
        {
          path: path.join(ENV.localAppData, 'Microsoft', 'Windows', 'Explorer'),
          pattern: /^(thumbcache|iconcache)_.*\.db$/i,
          shallow: true,
        },
      ],
    },
    {
      id: 'crash-dumps',
      name: 'Crash Dumps & Error Reports',
      description: 'Application crash dumps and Windows Error Reporting archives.',
      admin: false,
      risk: 'low',
      targets: [
        { path: path.join(ENV.localAppData, 'CrashDumps') },
        { path: path.join(ENV.localAppData, 'Microsoft', 'Windows', 'WER', 'ReportArchive') },
        { path: path.join(ENV.localAppData, 'Microsoft', 'Windows', 'WER', 'ReportQueue') },
        { path: path.join(ENV.programData, 'Microsoft', 'Windows', 'WER', 'ReportArchive') },
        { path: path.join(ENV.programData, 'Microsoft', 'Windows', 'WER', 'ReportQueue') },
      ],
    },
    {
      id: 'shader-cache',
      name: 'GPU / Shader Caches',
      description: 'DirectX, NVIDIA, AMD and Intel shader caches. Rebuilt on demand.',
      admin: false,
      risk: 'low',
      targets: [
        { path: path.join(ENV.localAppData, 'D3DSCache') },
        { path: path.join(ENV.localAppData, 'NVIDIA', 'DXCache') },
        { path: path.join(ENV.localAppData, 'NVIDIA', 'GLCache') },
        { path: path.join(ENV.localAppData, 'NVIDIA', 'ComputeCache') },
        { path: path.join(ENV.localAppData, 'AMD', 'DxCache') },
        { path: path.join(ENV.localAppData, 'AMD', 'DxcCache') },
        { path: path.join(ENV.localAppData, 'AMD', 'GLCache') },
        { path: path.join(ENV.localAppData, 'Intel', 'ShaderCache') },
      ],
    },
    {
      id: 'browser-cache',
      name: 'Browser Cache',
      description: 'Cache folders of installed browsers. Passwords, cookies, history and bookmarks are NOT touched.',
      admin: false,
      risk: 'low',
      note: 'Close your browsers first - open browsers lock most cache files.',
      targets: discoverBrowserTargets(),
    },
    {
      id: 'app-cache',
      name: 'Application Caches',
      description: 'Cache folders of common desktop applications (Discord, Teams, Spotify, ...).',
      admin: false,
      risk: 'low',
      targets: [
        { path: path.join(ENV.roamingAppData, 'discord', 'Cache') },
        { path: path.join(ENV.roamingAppData, 'discord', 'Code Cache') },
        { path: path.join(ENV.roamingAppData, 'discord', 'GPUCache') },
        { path: path.join(ENV.roamingAppData, 'Microsoft', 'Teams', 'Cache') },
        { path: path.join(ENV.roamingAppData, 'Microsoft', 'Teams', 'Code Cache') },
        { path: path.join(ENV.roamingAppData, 'Microsoft', 'Teams', 'GPUCache') },
        { path: path.join(ENV.localAppData, 'Spotify', 'Data') },
        { path: path.join(ENV.localAppData, 'Microsoft', 'Windows', 'Caches') },
        { path: path.join(ENV.localAppData, 'Packages', 'Microsoft.Windows.Cortana_cw5n1h2txyewy', 'LocalState', 'AppIconCache') },
      ],
    },
    {
      id: 'windows-update',
      name: 'Windows Update Download Cache',
      description: 'Already-installed update payloads in SoftwareDistribution\\Download.',
      admin: true,
      risk: 'medium',
      note: 'Requires administrator rights. Windows re-downloads anything it still needs.',
      defaultEnabled: false,
      targets: [{ path: path.join(ENV.windir, 'SoftwareDistribution', 'Download') }],
    },
    {
      id: 'delivery-optimization',
      name: 'Delivery Optimization Files',
      description: 'Peer-to-peer update cache maintained by the Delivery Optimization service.',
      admin: true,
      risk: 'medium',
      defaultEnabled: false,
      targets: [
        {
          path: path.join(
            ENV.windir, 'ServiceProfiles', 'NetworkService', 'AppData', 'Local',
            'Microsoft', 'Windows', 'DeliveryOptimization', 'Cache'
          ),
        },
      ],
    },
    {
      id: 'prefetch',
      name: 'Windows Prefetch',
      description: 'Boot/application prefetch data.',
      admin: true,
      risk: 'medium',
      note: 'Clearing prefetch can slow the next few application launches while it rebuilds.',
      defaultEnabled: false,
      targets: [{ path: path.join(ENV.windir, 'Prefetch'), pattern: /\.pf$/i, shallow: true }],
    },
  ];

  return categories
    .map((category) => {
      // Keep only targets that exist and pass the safety gate.
      const targets = (category.targets || [])
        .map((t) => ({ ...t, path: path.resolve(t.path) }))
        .filter((t) => {
          const verdict = isPathAllowed(t.path);
          if (!verdict.allowed) {
            logger.debug('cache-cleaner', `Target rejected (${verdict.reason})`, t.path);
            return false;
          }
          return dirExists(t.path);
        })
        // de-duplicate (e.g. %TEMP% == %LOCALAPPDATA%\Temp on most systems)
        .filter((t, index, list) => list.findIndex((o) => o.path.toLowerCase() === t.path.toLowerCase()) === index);

      const enabledByDefault = category.defaultEnabled !== false;
      return {
        ...category,
        targets,
        available: targets.length > 0,
        enabled:
          enabledByDefault &&
          !disabled.includes(category.id) &&
          (!category.admin || includeAdmin || system.isAdmin()),
      };
    })
    .filter((category) => category.available);
}

/* --------------------------------------------------------------------------
 * Scanning
 * ------------------------------------------------------------------------ */

/**
 * Recursively collect files below `root`.
 * @returns {Promise<{files:Array, dirs:string[], bytes:number, errors:Array}>}
 */
async function walk(root, options = {}) {
  const maxDepth = options.maxDepth || 12;
  const pattern = options.pattern || null;
  const shallow = options.shallow === true;

  const files = [];
  const dirs = [];
  const errors = [];
  let bytes = 0;

  async function visit(current, depth) {
    let entries;
    try {
      entries = await fsp.readdir(current, { withFileTypes: true });
    } catch (err) {
      errors.push({ path: current, code: err.code || 'ERR', message: err.message });
      return;
    }

    for (const entry of entries) {
      const full = path.join(current, entry.name);
      try {
        if (entry.isSymbolicLink()) continue; // never follow links/junctions
        if (entry.isDirectory()) {
          if (shallow || depth >= maxDepth) continue;
          dirs.push(full);
          await visit(full, depth + 1);
        } else if (entry.isFile()) {
          if (PROTECTED_FILE_NAMES.includes(entry.name.toLowerCase())) continue;
          if (pattern && !pattern.test(entry.name)) continue;
          const stat = await fsp.lstat(full);
          files.push({ path: full, size: stat.size, mtimeMs: stat.mtimeMs });
          bytes += stat.size;
        }
      } catch (err) {
        errors.push({ path: full, code: err.code || 'ERR', message: err.message });
      }
    }
  }

  await visit(root, 0);
  return { files, dirs, bytes, errors };
}

/** Scan every enabled category and return per-category statistics. */
async function scanCategories(categories, settings, onProgress) {
  const results = [];
  let index = 0;

  for (const category of categories) {
    index += 1;
    if (onProgress) onProgress(category, index, categories.length);

    const scanned = { category, files: [], dirs: [], bytes: 0, errors: [], targets: [] };

    for (const target of category.targets) {
      const walked = await walk(target.path, {
        maxDepth: settings.cleaner.maxScanDepth,
        pattern: target.pattern,
        shallow: target.shallow,
      });
      scanned.files.push(...walked.files);
      scanned.dirs.push(...walked.dirs);
      scanned.bytes += walked.bytes;
      scanned.errors.push(...walked.errors);
      scanned.targets.push({
        path: target.path,
        label: target.label || null,
        files: walked.files.length,
        bytes: walked.bytes,
        errors: walked.errors.length,
      });
    }

    results.push(scanned);
  }

  return results;
}

/* --------------------------------------------------------------------------
 * Cleaning
 * ------------------------------------------------------------------------ */

const LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ETXTBSY']);

/**
 * Delete the scanned files of one category.
 * `dryRun` is resolved by the caller from the module setting, the global
 * Dry Run switch and Safe Mode - it is never read from settings here.
 */
async function cleanCategory(scanned, settings, dryRun, opLog, onProgress) {
  const cutoff = Date.now() - settings.cleaner.skipFilesNewerThanMinutes * 60000;

  const result = {
    id: scanned.category.id,
    name: scanned.category.name,
    removed: 0,
    removedBytes: 0,
    skipped: 0,
    skippedBytes: 0,
    failed: 0,
    failedBytes: 0,
    removedDirs: 0,
    errors: [],
  };

  let processed = 0;
  for (const file of scanned.files) {
    processed += 1;
    if (onProgress && processed % 25 === 0) onProgress(processed, scanned.files.length);

    if (file.mtimeMs > cutoff) {
      result.skipped += 1;
      result.skippedBytes += file.size;
      continue;
    }

    // Defence in depth: re-check every single path right before deleting it.
    const verdict = isPathAllowed(file.path);
    if (!verdict.allowed) {
      result.skipped += 1;
      result.skippedBytes += file.size;
      opLog.line(`SKIP  (${verdict.reason}) ${file.path}`);
      continue;
    }

    if (dryRun) {
      result.removed += 1;
      result.removedBytes += file.size;
      continue;
    }

    try {
      await fsp.unlink(file.path);
      result.removed += 1;
      result.removedBytes += file.size;
    } catch (err) {
      if (err.code === 'ENOENT') {
        result.skipped += 1;
        continue;
      }
      result.failed += 1;
      result.failedBytes += file.size;
      const reason = LOCK_CODES.has(err.code) ? 'in use / access denied' : err.code || 'error';
      if (result.errors.length < 200) result.errors.push({ path: file.path, code: err.code, reason });
      opLog.line(`FAIL  (${reason}) ${file.path}`);
    }
  }

  if (onProgress) onProgress(scanned.files.length, scanned.files.length);

  // Remove folders that are now empty (deepest first, target roots kept).
  if (settings.cleaner.removeEmptyFolders && !dryRun) {
    const rootPaths = scanned.category.targets.map((t) => path.resolve(t.path).toLowerCase());
    const sorted = [...new Set(scanned.dirs)].sort((a, b) => b.length - a.length);
    for (const dir of sorted) {
      if (rootPaths.includes(path.resolve(dir).toLowerCase())) continue;
      if (!isPathAllowed(dir).allowed) continue;
      try {
        await fsp.rmdir(dir);
        result.removedDirs += 1;
      } catch {
        /* not empty or locked - perfectly normal */
      }
    }
  }

  return result;
}

/* --------------------------------------------------------------------------
 * Recycle Bin (separate, opt-in action)
 * ------------------------------------------------------------------------ */

const RECYCLE_QUERY = [
  '$ErrorActionPreference = "SilentlyContinue"',
  '$shell = New-Object -ComObject Shell.Application',
  '$bin = $shell.Namespace(0xA)',
  '$count = 0; $size = 0',
  'if ($bin) {',
  '  foreach ($item in $bin.Items()) {',
  '    $count++',
  '    try { $size += [long]$item.ExtendedProperty("Size") } catch { }',
  '  }',
  '}',
  '[ordered]@{ count = $count; size = $size } | ConvertTo-Json -Compress',
].join('\n');

async function inspectRecycleBin() {
  const result = await system.runPowerShellJson(RECYCLE_QUERY, { timeoutMs: 60000 });
  if (!result.ok || !result.data) return { ok: false, count: 0, size: 0, error: result.stderr };
  return { ok: true, count: Number(result.data.count) || 0, size: Number(result.data.size) || 0 };
}

async function emptyRecycleBin() {
  const script = [
    '$ErrorActionPreference = "Stop"',
    'try {',
    '  Clear-RecycleBin -Force -ErrorAction Stop',
    '  "OK"',
    '} catch {',
    '  if ($_.Exception.Message -match "empty") { "EMPTY" } else { "ERROR: " + $_.Exception.Message }',
    '}',
  ].join('\n');
  const result = await system.runPowerShell(script, { timeoutMs: 120000 });
  const text = result.stdout.trim();
  if (text.startsWith('OK')) return { ok: true };
  if (text.startsWith('EMPTY')) return { ok: true, alreadyEmpty: true };
  return { ok: false, error: text || result.stderr || 'unknown error' };
}

/* --------------------------------------------------------------------------
 * Presentation
 * ------------------------------------------------------------------------ */

function renderScanTable(ui, scans) {
  const rows = scans.map((scan) => [
    ui.c.brightWhite(scan.category.name),
    ui.c.gray(String(scan.category.targets.length)),
    ui.c.brightYellow(system.formatNumber(scan.files.length)),
    ui.c.brightCyan(system.formatBytes(scan.bytes)),
    scan.errors.length ? ui.c.red(String(scan.errors.length)) : ui.c.gray('0'),
  ]);

  const totals = scans.reduce(
    (acc, scan) => ({
      files: acc.files + scan.files.length,
      bytes: acc.bytes + scan.bytes,
      errors: acc.errors + scan.errors.length,
    }),
    { files: 0, bytes: 0, errors: 0 }
  );

  rows.push([
    ui.c.combo('bold', 'brightGreen')('TOTAL'),
    '',
    ui.c.combo('bold', 'brightGreen')(system.formatNumber(totals.files)),
    ui.c.combo('bold', 'brightGreen')(system.formatBytes(totals.bytes)),
    totals.errors ? ui.c.red(String(totals.errors)) : ui.c.gray('0'),
  ]);

  ui.table(['CATEGORY', 'PATHS', 'FILES', 'SIZE', 'ERR'], rows, { align: ['left', 'right', 'right', 'right', 'right'] });
  return totals;
}

function renderLocations(ui, scans) {
  for (const scan of scans) {
    ui.println('  ' + ui.c.brightYellow(ui.glyphs().arrow + ' ' + scan.category.name));
    for (const target of scan.targets) {
      const size = ui.c.gray(`(${system.formatNumber(target.files)} files, ${system.formatBytes(target.bytes)})`);
      ui.println('      ' + ui.c.gray(ui.truncate(target.path, ui.getWidth() - 6)) + ' ' + size);
    }
  }
}

/* --------------------------------------------------------------------------
 * Main flow
 * ------------------------------------------------------------------------ */

/**
 * @param {object} ctx
 * @param {string[]|null} selectedIds  category ids, or null for "every enabled"
 * @param {{forceDryRun?:boolean}} [options]
 */
async function runCleaner(ctx, selectedIds, options = {}) {
  const { ui, prompt, settings } = ctx;
  const started = Date.now();

  // Dry run wins from three places: this call, the global switch (which Safe
  // Mode also sets) and the module setting.
  const dryRun = Boolean(options.forceDryRun || ctx.dryRun || settings.cleaner.dryRun);

  let categories = buildCategories(settings);
  if (selectedIds) {
    categories = categories.filter((c) => selectedIds.includes(c.id));
  } else {
    categories = categories.filter((c) => c.enabled);
  }

  if (!categories.length) {
    ui.warning('No cache categories are available or enabled on this system.');
    return { ok: false, reason: 'no-categories' };
  }

  const adminNeeded = categories.filter((c) => c.admin && !ctx.isAdmin);
  if (adminNeeded.length) {
    ui.blank();
    ui.warning('These categories need administrator rights - files that are protected will be reported as skipped:');
    for (const category of adminNeeded) ui.println('      ' + ui.c.gray(ui.glyphs().bullet + ' ' + category.name));
  }

  /* ---- scan ---------------------------------------------------------- */
  ui.blank();
  ui.section('SCAN');
  logger.info('cache-cleaner', 'Cleanup scan started', { categories: categories.map((c) => c.id) });

  const spin = ui.spinner('Scanning cache locations...');
  let scans;
  try {
    scans = await scanCategories(categories, settings, (category, index, total) => {
      spin.update(`Scanning [${index}/${total}] ${category.name}...`);
    });
  } catch (err) {
    spin.stop('ERROR', `The scan failed: ${err.message}`);
    logger.exception('cache-cleaner', 'Cleanup scan failed', err);
    return { ok: false, reason: 'scan-failed' };
  }
  spin.stop('OK', `Scan completed in ${((Date.now() - started) / 1000).toFixed(1)}s`);

  ui.blank();
  ui.println('  ' + ui.c.brightWhite('Locations to be cleaned'));
  ui.blank();
  renderLocations(ui, scans);

  ui.blank();
  const totals = renderScanTable(ui, scans);

  ui.blank();
  ui.info(`Approximate files to remove : ${ui.c.brightYellow(system.formatNumber(totals.files))}`);
  ui.info(`Approximate space to recover: ${ui.c.brightGreen(system.formatBytes(totals.bytes))}`);
  if (settings.cleaner.skipFilesNewerThanMinutes > 0) {
    ui.info(ui.c.gray(`Files modified in the last ${settings.cleaner.skipFilesNewerThanMinutes} minute(s) will be skipped.`));
  }
  if (dryRun) {
    ui.blank();
    ui.warning(
      ctx.safeMode
        ? 'SAFE MODE is enabled - this run only reports, nothing is deleted.'
        : 'DRY RUN is enabled - nothing will actually be deleted.'
    );
  }

  if (totals.files === 0) {
    ui.blank();
    ui.completed('Nothing to clean - these locations are already empty.');
    logger.info('cache-cleaner', 'Cleanup finished: nothing to clean');
    return { ok: true, removed: 0, bytes: 0 };
  }

  /* ---- confirm ------------------------------------------------------- */
  const mustConfirm =
    (settings.cleaner.confirmBeforeDelete || settings.security.confirmDestructive) && !dryRun;
  if (mustConfirm) {
    const confirmed = await prompt.confirm(
      `Delete ${system.formatNumber(totals.files)} file(s) and recover ${system.formatBytes(totals.bytes)}?`,
      { danger: true }
    );
    if (confirmed !== true) {
      ui.blank();
      ui.info('Cleanup cancelled - nothing was deleted.');
      logger.info('cache-cleaner', 'Cleanup cancelled by user');
      return { ok: false, reason: 'cancelled' };
    }
  }

  /* ---- clean --------------------------------------------------------- */
  const opLog = logger.createOperationLog('cleanup');
  opLog.write('INFINITYSNIPER HFX - CLEANUP LOG');
  opLog.write('='.repeat(60));
  opLog.line(`Started    : ${logger.timeStamp()}`);
  opLog.line(`User       : ${os.userInfo().username}${ctx.isAdmin ? ' (administrator)' : ''}`);
  opLog.line(`Machine    : ${os.hostname()}`);
  opLog.line(`Dry run    : ${dryRun ? 'YES (no deletion performed)' : 'no'}`);
  opLog.line(`Skip newer : ${settings.cleaner.skipFilesNewerThanMinutes} minute(s)`);
  opLog.section('planned targets');
  for (const scan of scans) {
    opLog.write(`[${scan.category.id}] ${scan.category.name}`);
    for (const target of scan.targets) {
      opLog.write(`    ${target.path}  ->  ${target.files} files, ${system.formatBytes(target.bytes)}`);
    }
  }
  opLog.section('operations');

  ui.blank();
  ui.section('CLEANING');
  logger.info('cache-cleaner', 'Cleanup started', { files: totals.files, bytes: totals.bytes });

  const results = [];
  for (const scan of scans) {
    ui.status('PROCESSING', ui.c.white(scan.category.name));
    opLog.line(`--- ${scan.category.name} (${scan.files.length} files) ---`);

    let result;
    try {
      result = await cleanCategory(scan, settings, dryRun, opLog, (done, total) => {
        ui.progressLine('   ' + scan.category.name, done, total);
      });
    } catch (err) {
      ui.endProgressLine();
      ui.error(`${scan.category.name}: ${err.message}`);
      opLog.line(`ERROR ${scan.category.name}: ${err.message}`);
      logger.exception('cache-cleaner', `Cleanup error in ${scan.category.id}`, err);
      results.push({
        id: scan.category.id, name: scan.category.name,
        removed: 0, removedBytes: 0, skipped: 0, skippedBytes: 0,
        failed: scan.files.length, failedBytes: scan.bytes, removedDirs: 0,
        errors: [{ path: scan.category.name, reason: err.message }],
      });
      continue;
    }
    ui.endProgressLine();

    const verb = dryRun ? 'would remove' : 'removed';
    ui.status(
      result.failed ? 'WARNING' : 'OK',
      ui.padEnd(ui.c.white(scan.category.name), 34) +
        ui.c.gray(verb + ' ') + ui.c.brightGreen(system.formatNumber(result.removed)) +
        ui.c.gray(' files, ') + ui.c.brightCyan(system.formatBytes(result.removedBytes)) +
        (result.failed ? ui.c.red(`  (${result.failed} in use)`) : '')
    );

    opLog.line(
      `RESULT ${scan.category.name}: removed=${result.removed} (${system.formatBytes(result.removedBytes)}), ` +
      `skipped=${result.skipped}, failed=${result.failed}, empty folders removed=${result.removedDirs}`
    );
    results.push(result);
  }

  /* ---- summary ------------------------------------------------------- */
  const summary = results.reduce(
    (acc, r) => ({
      removed: acc.removed + r.removed,
      removedBytes: acc.removedBytes + r.removedBytes,
      skipped: acc.skipped + r.skipped,
      failed: acc.failed + r.failed,
      dirs: acc.dirs + r.removedDirs,
      errors: acc.errors + r.errors.length,
    }),
    { removed: 0, removedBytes: 0, skipped: 0, failed: 0, dirs: 0, errors: 0 }
  );

  opLog.section('summary');
  opLog.line(`Files removed          : ${summary.removed}`);
  opLog.line(`Files skipped          : ${summary.skipped}`);
  opLog.line(`Files not removable    : ${summary.failed}`);
  opLog.line(`Empty folders removed  : ${summary.dirs}`);
  opLog.line(`Space recovered        : ${system.formatBytes(summary.removedBytes)}`);
  opLog.line(`Categories cleaned     : ${results.map((r) => r.name).join(', ')}`);
  opLog.line(`Duration               : ${((Date.now() - started) / 1000).toFixed(1)}s`);
  opLog.close();

  ui.blank();
  ui.section('RESULT');
  ui.blank();

  ui.table(
    ['CATEGORY', 'REMOVED', 'RECOVERED', 'SKIPPED', 'IN USE'],
    results.map((r) => [
      ui.c.brightWhite(r.name),
      ui.c.brightGreen(system.formatNumber(r.removed)),
      ui.c.brightCyan(system.formatBytes(r.removedBytes)),
      ui.c.gray(system.formatNumber(r.skipped)),
      r.failed ? ui.c.red(system.formatNumber(r.failed)) : ui.c.gray('0'),
    ]),
    { align: ['left', 'right', 'right', 'right', 'right'] }
  );

  ui.blank();
  ui.println('  ' + ui.c.gray('Files Removed          : ') + ui.c.brightGreen(system.formatNumber(summary.removed)));
  ui.println('  ' + ui.c.gray('Files Skipped          : ') + ui.c.brightYellow(system.formatNumber(summary.skipped)));
  ui.println('  ' + ui.c.gray('Could Not Be Removed   : ') + (summary.failed ? ui.c.brightRed(system.formatNumber(summary.failed)) : ui.c.gray('0')));
  ui.println('  ' + ui.c.gray('Empty Folders Removed  : ') + ui.c.brightWhite(system.formatNumber(summary.dirs)));
  ui.println('  ' + ui.c.gray('Categories Cleaned     : ') + ui.c.brightWhite(results.length + ' (' + results.map((r) => r.name).join(', ') + ')'));
  ui.println('  ' + ui.c.gray('Space Recovered        : ') + ui.c.combo('bold', 'brightGreen')(system.formatBytes(summary.removedBytes)));
  ui.println('  ' + ui.c.gray('Errors                 : ') + (summary.errors ? ui.c.brightRed(String(summary.errors)) : ui.c.gray('none')));
  ui.println('  ' + ui.c.gray('Duration               : ') + ui.c.brightWhite(((Date.now() - started) / 1000).toFixed(1) + 's'));

  if (summary.failed) {
    ui.blank();
    ui.warning('Some files could not be removed because they are currently in use.');
    ui.println('  ' + ui.c.gray('Close the related applications (browsers, Explorer, games) and run the cleaner again.'));
    const sample = results.flatMap((r) => r.errors).slice(0, 5);
    for (const entry of sample) {
      ui.println('      ' + ui.c.gray(ui.truncate(entry.path, ui.getWidth() - 6)) + ' ' + ui.c.red(`[${entry.reason}]`));
    }
  }

  ui.blank();
  ui.completed(dryRun ? 'Dry run finished - no file was deleted.' : 'Cleanup completed.');
  ui.println('  ' + ui.c.gray('Cleanup log: ') + ui.c.brightCyan(opLog.file));

  logger.info('cache-cleaner', 'Cleanup completed', summary);
  if (summary.failed) logger.warn('cache-cleaner', 'Cleanup errors', `${summary.failed} file(s) in use`);

  return { ok: true, ...summary, log: opLog.file };
}

/* --------------------------------------------------------------------------
 * Sub menu
 * ------------------------------------------------------------------------ */

async function selectCategories(ctx) {
  const { ui, prompt, settings } = ctx;
  const categories = buildCategories(settings);

  ui.blank();
  ui.section('AVAILABLE CATEGORIES');
  ui.blank();
  categories.forEach((category, index) => {
    const state = category.enabled ? ui.c.brightGreen('[enabled] ') : ui.c.gray('[disabled]');
    const admin = category.admin ? ui.c.yellow(' (admin)') : '';
    ui.println('  ' + ui.c.brightYellow(ui.padEnd(`[${index + 1}]`, 5)) + state + ' ' + ui.c.brightWhite(category.name) + admin);
    ui.println('        ' + ui.c.gray(ui.truncate(category.description, ui.getWidth() - 4)));
    if (category.note) ui.println('        ' + ui.c.yellow(ui.glyphs().bullet + ' ' + ui.truncate(category.note, ui.getWidth() - 6)));
  });

  ui.blank();
  ui.println('  ' + ui.c.brightYellow(ui.padEnd(`[${categories.length + 1}]`, 5)) + ui.c.brightWhite('Every category shown above'));
  ui.println('  ' + ui.c.gray(ui.padEnd('[0]', 5) + 'Back'));
  ui.blank();
  ui.println('  ' + ui.c.gray('Several numbers can be combined, for example: 1,3,4'));

  const answer = await prompt.text('Categories');
  if (!answer || answer === '0') return null;
  if (answer === String(categories.length + 1)) return categories.map((c) => c.id);

  const chosen = answer
    .split(/[,\s]+/)
    .map((token) => Number(token))
    .filter((n) => Number.isInteger(n) && n >= 1 && n <= categories.length)
    .map((n) => categories[n - 1].id);

  return chosen.length ? [...new Set(chosen)] : null;
}

async function recycleBinAction(ctx) {
  const { ui, prompt } = ctx;
  ui.blank();
  ui.section('RECYCLE BIN');
  ui.blank();

  const spin = ui.spinner('Inspecting the Recycle Bin...');
  const info = await inspectRecycleBin();
  spin.stop();

  if (!info.ok) {
    ui.warning('The Recycle Bin could not be inspected.');
    if (info.error) ui.println('  ' + ui.c.gray(ui.truncate(info.error.split('\n')[0], ui.getWidth())));
  } else {
    ui.info(`Items in Recycle Bin : ${ui.c.brightYellow(system.formatNumber(info.count))}`);
    ui.info(`Space used           : ${ui.c.brightCyan(system.formatBytes(info.size))}`);
    if (info.count === 0) {
      ui.blank();
      ui.completed('The Recycle Bin is already empty.');
      return;
    }
  }

  ui.blank();
  ui.warning('Emptying the Recycle Bin permanently deletes its contents - this cannot be undone.');
  if (ctx.dryRun) {
    ui.info('Dry Run is enabled - the Recycle Bin was left untouched.');
    return;
  }
  const confirmed = await prompt.confirm('Empty the Recycle Bin now?', { danger: true });
  if (confirmed !== true) {
    ui.info('Cancelled - the Recycle Bin was not touched.');
    return;
  }

  const spin2 = ui.spinner('Emptying the Recycle Bin...');
  const result = await emptyRecycleBin();
  spin2.stop();

  if (result.ok) {
    ui.completed(result.alreadyEmpty ? 'The Recycle Bin was already empty.' : 'Recycle Bin emptied.');
    logger.info('cache-cleaner', 'Recycle Bin emptied', { items: info.count, bytes: info.size });
  } else {
    ui.error(`The Recycle Bin could not be emptied: ${result.error}`);
    logger.error('cache-cleaner', 'Recycle Bin could not be emptied', result.error);
  }
}

async function execute(ctx) {
  const { ui, prompt, settings, configApi } = ctx;

  if (!system.IS_WINDOWS) {
    ui.error('This module targets Windows cache locations and cannot run on this platform.');
    return { ok: false, reason: 'unsupported-platform' };
  }

  let running = true;
  while (running) {
    ui.blank();
    ui.box({
      title: 'CACHE & TEMPORARY FILE CLEANER',
      lines: [
        ui.c.gray('Scans first, deletes only after confirmation.'),
        ui.c.gray('Documents, downloads, saves and logins are never touched.'),
      ],
      color: 'brightCyan',
    });

    const available = buildCategories(settings);
    const dryRunActive = Boolean(ctx.dryRun || settings.cleaner.dryRun);
    ui.blank();
    ui.statusBar([
      ['Categories', String(available.length)],
      ['Enabled', String(available.filter((c) => c.enabled).length), 'brightGreen'],
      ['Dry run', dryRunActive ? 'ON' : 'off', dryRunActive ? 'brightYellow' : 'gray'],
      ['Elevated', ctx.isAdmin ? 'yes' : 'no', ctx.isAdmin ? 'brightGreen' : 'gray'],
    ]);
    ui.blank();

    ui.menu(
      [
        { key: '1', label: 'Scan & clean enabled categories' },
        { key: '2', label: 'Choose categories manually' },
        { key: '3', label: 'Scan only (no deletion)' },
        { key: '4', label: 'Empty Recycle Bin', color: 'brightRed' },
        { key: '5', label: `Toggle dry-run mode  (currently ${settings.cleaner.dryRun ? 'ON' : 'OFF'})` },
        { separator: true },
        { key: '0', label: 'Back to the main menu', color: 'gray' },
      ],
      { title: 'CLEANER OPTIONS' }
    );
    ui.blank();

    const choice = await prompt.select();

    // End of input (piped script, Ctrl+Z): return to the caller.
    if (!choice && prompt.isClosed && prompt.isClosed()) break;

    try {
      switch (choice) {
        case '1':
          await runCleaner(ctx, null);
          await prompt.pause();
          break;

        case '2': {
          const ids = await selectCategories(ctx);
          if (!ids) {
            ui.info('No categories selected.');
            break;
          }
          await runCleaner(ctx, ids);
          await prompt.pause();
          break;
        }

        case '3':
          // Forced dry run for this call only - settings are never mutated.
          await runCleaner(ctx, null, { forceDryRun: true });
          await prompt.pause();
          break;

        case '4':
          await recycleBinAction(ctx);
          await prompt.pause();
          break;

        case '5': {
          const value = !settings.cleaner.dryRun;
          try {
            ctx.settings = configApi.set('cleaner.dryRun', value);
            settings.cleaner.dryRun = value;
            ui.ok(`Dry-run mode is now ${value ? 'ON (nothing will be deleted)' : 'OFF'}.`);
          } catch (err) {
            ui.warning(`The setting could not be saved: ${err.message}`);
          }
          break;
        }

        case '0':
          running = false;
          break;

        default:
          ui.warning('Enter one of the numbers shown above, or 0 to go back.');
      }
    } catch (err) {
      ui.blank();
      ui.error(`The operation failed: ${err.message}`);
      logger.exception('cache-cleaner', 'Unhandled cleaner error', err);
      await prompt.pause('Press ENTER to continue');
    }
  }

  return { ok: true };
}

module.exports = {
  name: 'Cache & Temporary File Cleaner',
  menuName: 'Cache & Temporary File Cleaner',
  description: 'Safely scans and clears Windows temp, cache and thumbnail locations with a full report and log.',
  version: '1.0.0',
  author: 'InfinitySniper HFX',
  category: 'Maintenance',
  order: 10,
  requiredPermissions: 'normal',
  destructive: true,
  dependencies: [],
  minimumAppVersion: '1.0.0',
  permissions: ['filesystem:read', 'filesystem:delete(cache locations)', 'shell:recycle-bin'],
  tags: ['cleaner', 'temp', 'cache', 'maintenance', 'clean'],
  config: {
    dryRun: false,
    confirmBeforeDelete: true,
    skipFilesNewerThanMinutes: 5,
  },
  documentation:
    'Scans known Windows and application cache locations, shows how many files and how much space ' +
    'can be recovered, then deletes them after a numeric confirmation. Files in use are skipped, ' +
    'never forced. Safe Mode blocks this function; Dry Run makes it report without deleting. ' +
    'A detailed cleanup log is written to the logs folder.',

  async selfTest(ctx) {
    const categories = buildCategories(ctx.settings);
    return categories.length
      ? { ok: true, message: `${categories.length} cache categories available` }
      : { ok: false, message: 'no cache location found' };
  },

  execute,
};
