// Native fetch available in Node 20+
import * as crypto from 'crypto';

export interface QuarkConfig {
  cookie: string;
  nickname?: string;
}

export interface StorageInfo {
  used: string;
  total: string;
  usedBytes: number;
  totalBytes: number;
}

interface ShareFile {
  fid: string;
  file_name: string;
  share_fid_token: string;
  dir: boolean;
  size?: number;
}

export class QuarkDriver {
  private config: QuarkConfig;
  private baseUrl = 'https://drive-pc.quark.cn';
  private cachedUsedSpace: { bytes: number; hourBlock: number } | null = null;

  constructor(config: QuarkConfig) {
    this.config = config;
  }

  private getHeaders(): Record<string, string> {
    return {
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      'Cookie': this.config.cookie,
      'Accept': 'application/json, text/plain, */*',
      'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
      'Referer': 'https://pan.quark.cn/',
      'Origin': 'https://pan.quark.cn',
    };
  }

  private getCommonParams(): Record<string, string> {
    return { pr: 'ucpro', fr: 'pc' };
  }

  /** Random delay to mimic human behavior (500-2000ms) */
  private async humanDelay(): Promise<void> {
    const ms = Math.floor(Math.random() * 1500) + 500;
    await new Promise(r => setTimeout(r, ms));
  }

  /** Generate a random password for share links */
  private randomSharePwd(): string {
    return Math.floor(1000 + Math.random() * 9000).toString();
  }

  /** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
  private dailyFolderName(): string {
    const d = new Date();
    const y = d.getFullYear();
    const m = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    return `${y}-${m}-${day}`;
  }

  /** Generate a random folder name for saving (fallback) */
  private randomFolderName(): string {
    const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
    let name = '';
    for (let i = 0; i < 12; i++) {
      name += chars[Math.floor(Math.random() * chars.length)];
    }
    return name;
  }

  /** Generate query string with common params + random timing to mimic browser */
  private makeQuery(extra: Record<string, string> = {}): string {
    const __dt = Math.floor(Math.random() * 240000 + 60000);
    const __t = Date.now() / 1000;
    return new URLSearchParams({
      ...this.getCommonParams(),
      uc_param_str: '',
      app: 'clouddrive',
      __dt: String(__dt),
      __t: String(__t),
      ...extra,
    }).toString();
  }

  /**
   * Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
   */
  private getMparam(): { kps?: string; sign?: string; vcode?: string } {
    const cookie = this.config.cookie;
    const kpsMatch = cookie.match(/(?<!\w)kps=([a-zA-Z0-9%+/=]+)[;&]?/);
    const signMatch = cookie.match(/(?<!\w)sign=([a-zA-Z0-9%+/=]+)[;&]?/);
    const vcodeMatch = cookie.match(/(?<!\w)vcode=([a-zA-Z0-9%+/=]+)[;&]?/);
    if (kpsMatch && signMatch && vcodeMatch) {
      return {
        kps: kpsMatch[1],
        sign: signMatch[1].replace(/%25/g, '%'),
        vcode: vcodeMatch[1],
      };
    }
    return {};
  }

  /**
   * Chinese → homophonic character mapping (same sound, different char to avoid keyword detection).
   * 50% chance: use homophonic chars (looks like Chinese but different)
   * 50% chance: use pinyin initials
   */
  private static readonly HOMOPHONE_MAP: Record<string, string> = {
    // 网盘热门番名 — 谐音替换 (same sound, different char)
    '斗':'陡','破':'坡','苍':'仓','穹':'穷',
    '完':'玩','美':'每','世':'士','界':'介',
    '凡':'烦','人':'仁','修':'休','罗':'络',
    '仙':'先','逆':'腻','遮':'折','天':'添',
    '吞':'屯','噬':'逝','大':'达','主':'嘱','宰':'崽',
    '星':'惺','辰':'晨','变':'便','一':'伊','念':'捻',
    '永':'泳','恒':'横','神':'申','墓':'暮','长':'尝','生':'甥',
    '剑':'箭','来':'莱','诡':'鬼','秘':'蜜',
    '全':'泉','职':'值','盘':'磐','龙':'笼',
    '雪':'血','鹰':'莺','莽':'蟒','荒':'慌','纪':'记',
    '珠':'株','王':'亡','座':'坐','牧':'木','记':'计',
    '沧':'舱','元':'圆','图':'涂','紫':'仔','川':'串',
    '百':'白','炼':'恋','成':'程','饶':'绕','命':'冥',
    // 通用谐音替换
    '的':'得','了':'啦','是':'事','不':'布','我':'窝',
    '你':'尼','他':'她','有':'友','和':'合','与':'予',
    '上':'尚','下':'夏','中':'忠','第':'弟','集':'级',
    '话':'划','季':'际','年':'念','月':'阅','日':'曰',
    '新':'心','版':'板','高':'糕','清':'青','原':'源',
    '小':'晓','片':'篇','视':'市','频':'贫','道':'到',
    '动':'洞','画':'话','声':'升','音':'因','文':'闻',
    '明':'名','暗':'黯','光':'广','影':'映','色':'瑟',
    '风':'疯','雨':'语','花':'华','国':'果','家':'佳',
    '战':'站','争':'挣','士':'仕','兵':'宾',
    '皇':'惶','帝':'谛','魔':'磨','鬼':'诡','怪':'乖',
    '精':'经','灵':'铃','妖':'夭','武':'舞','侠':'狭',
    '杀':'刹','血':'雪','刀':'叨','枪':'呛','炮':'泡',
    '时':'石','空':'孔','前':'钱','后':'厚','东':'冬',
    '南':'难','西':'夕','北':'备','开':'凯','关':'官',
    '出':'初','进':'近','去':'趣',
    '短':'短','多':'多','少':'少','真':'贞','假':'价',
    '好':'郝','坏':'怀','对':'队','错':'措','以':'已',
    '从':'从','被':'被','把':'把','将':'将','在':'在',
    '但':'但','就':'就','才':'才','也':'也','很':'狠',
    '又':'又','再':'再','更':'更','最':'最','总':'总',
    '共':'共','只':'只','各':'各','每':'每','任':'任',
    '所':'所','该':'该','本':'本',
  };

  /** Convert Chinese text to homophonic (substitute chars with same sound) */
  private homophonicText(text: string): string {
    let result = '';
    for (const ch of text) {
      if (/[\u4e00-\u9fff]/.test(ch)) {
        const homophone = QuarkDriver.HOMOPHONE_MAP[ch];
        result += homophone || ch;
      } else {
        result += ch;
      }
    }
    return result;
  }

  /** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */
  private pinyinLike(text: string): string {
    let result = '';
    for (const ch of text) {
      if (/[\u4e00-\u9fff]/.test(ch)) {
        const homophone = QuarkDriver.HOMOPHONE_MAP[ch];
        if (homophone) {
          // Take first letter of homophone pinyin
          result += this.pinyinInitial(homophone);
        } else {
          // Fallback: use codepoint to generate a letter
          const code = ch.charCodeAt(0);
          result += String.fromCharCode(97 + (code % 26));
        }
      } else if (/[a-zA-Z0-9]/.test(ch)) {
        result += ch;
      } else if (/[\s._-]/.test(ch)) {
        result += '_';
      }
    }
    return result.replace(/_+/g, '_').replace(/^_|_$/g, '');
  }

  /** Get pinyin initial (first letter of pinyin) for a Chinese character */
  private pinyinInitial(ch: string): string {
    // This is a simplified pinyin initial mapping based on unicode range
    // Real pinyin would need a full library, this is a workable approximation
    const code = ch.charCodeAt(0);
    if (code >= 0x4E00 && code <= 0x9FFF) {
      // Crude but functional: map unicode range to initial letters
      // Based on real pinyin distributions
      const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w'];
      const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1);
      return initials[idx];
    }
    return ch.toLowerCase();
  }

  private static readonly NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' +
    '么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈';
  /**
   * Anti-harmony rename for directories.
   * NEW APPROACH: light homophonic replacement + preserve structure.
   * Goal: still recognizable to humans, but doesn't match keyword filters.
   *
   * 妖神记 第九季 (2025) 更新432
   * → 夭申记 弟九季 (2025) 更新432_9b03   (80%: light homophonic)
   * → ys记 第9季 (2025) gx432_9b03         (20%: partial pinyin)
   */
  private magicRenameDir(dirName: string): string {
    const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4);

    // Clean up: replace multiple spaces, trim
    let cleanName = dirName.trim().replace(/\s+/g, ' ');

    if (!cleanName) {
      return `media_${hash}`;
    }

    // Two strategies: light homophonic (80%) or partial pinyin (20%)
    let baseName: string;

    if (Math.random() < 0.2) {
      // Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is
      const chars = [...cleanName];
      const result: string[] = [];
      for (const ch of chars) {
        if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) {
          result.push(this.pinyinInitial(ch));
        } else {
          result.push(ch);
        }
      }
      baseName = result.join('');
    } else {
      // Light homophonic: replace each CJK char, keep everything else as-is
      const chars = [...cleanName];
      const result: string[] = [];
      for (const ch of chars) {
        if (/[\u4e00-\u9fff]/.test(ch)) {
          result.push(QuarkDriver.HOMOPHONE_MAP[ch] || ch);
        } else {
          result.push(ch);
        }
      }
      baseName = result.join('');

      // Optional: insert 0-2 light noise chars (low probability)
      const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0;
      for (let n = 0; n < noiseCount; n++) {
        const pos = Math.floor(Math.random() * (baseName.length + 1));
        const ink = QuarkDriver.NOISE_CJK[Math.floor(Math.random() * QuarkDriver.NOISE_CJK.length)];
        baseName = baseName.slice(0, pos) + ink + baseName.slice(pos);
      }
    }

    // Cleanup: replace non-alphanumeric/CJK chars with underscore
    baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_');
    baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, '');
    if (baseName.length > 30) baseName = baseName.slice(0, 30);

    // Append hash suffix
    return `${baseName}_${hash}`;
  }

  /**
   * Anti-harmony rename for files.
   * KEEPS: episode numbers (第01集, 01, Ep1), quality (4K, 1080P, HDR), language tags, original extension
   * REPLACES: Chinese title with homophonic/pinyin
   * 
   * Original: 斗破苍穹_第01集_1080p.mp4
   * Result:   陡坡仓穷_Ep01_1080p_x9k2.mp4
   */
  private magicRename(filename: string): string {
    const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8);
    
    let ext = '';
    const extMatch = filename.match(/\.[a-zA-Z0-9]+$/);
    if (extMatch) {
      ext = extMatch[0];
      filename = filename.slice(0, -ext.length);
    }
    
    // Extract and REMEMBER: episode info, quality, language, year
    const episodePatterns = [
      { regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m: string) => 'Ep' + m.replace(/[^\d]/g, '') },
      { regex: /Ep\d+|ep\d+/i, format: (m: string) => m.toUpperCase() },
      { regex: /Part\s*\d+/i, format: (m: string) => m.replace(/\s+/g, '') },
      { regex: /E\d{2,}/i, format: (m: string) => m.toUpperCase() },
    ];
    let episodeTag = '';
    for (const { regex, format } of episodePatterns) {
      const m = filename.match(regex);
      if (m) {
        episodeTag = format(m[0]);
        filename = filename.replace(m[0], '');
        break;
      }
    }
    
    // Extract and REMEMBER: quality tags
    const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/;
    const qualityMatch = filename.match(qualityPattern);
    const qualityTag = qualityMatch ? qualityMatch[0] : '';
    if (qualityMatch) filename = filename.replace(qualityMatch[0], '');
    
    // Extract and REMEMBER: language tags
    const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/;
    const langMatch = filename.match(langPattern);
    const langTag = langMatch ? langMatch[0] : '';
    if (langMatch) filename = filename.replace(langMatch[0], '');
    
    // Extract and REMEMBER: year
    const yearMatch = filename.match(/\b(20\d{2})\b/);
    const yearTag = yearMatch ? yearMatch[0] : '';
    if (yearMatch) filename = filename.replace(yearMatch[0], '');
    
    // Extract and REMEMBER: season info
    const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/);
    const seasonTag = seasonMatch ? `${seasonMatch[1]}季` : '';
    if (seasonMatch) filename = filename.replace(seasonMatch[0], '');
    
    // Now process the remaining name (mostly Chinese title)
    // Clean up separators
    filename = filename.replace(/[._\-【】\[\]（）()\s]+/g, '_').trim();
    
    // Decide: homophonic or pinyin-initial
    const useHomophonic = Math.random() > 0.5;
    let titlePart: string;
    if (useHomophonic) {
      titlePart = this.homophonicText(filename);
      // Remove non-title chars
      titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_');
      titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
      if (titlePart.length > 15) titlePart = titlePart.slice(0, 15);
    } else {
      titlePart = this.pinyinLike(filename);
      titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_');
      titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
      if (titlePart.length > 15) titlePart = titlePart.slice(0, 15);
    }
    
    // Remove sensitive keywords from title part
    const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig;
    titlePart = titlePart.replace(sensitiveWords, '');
    titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
    
    // Build preserved tags
    const tags: string[] = [];
    if (seasonTag) tags.push(seasonTag);
    if (episodeTag) tags.push(episodeTag);
    if (qualityTag) tags.push(qualityTag.toUpperCase());
    if (langTag) tags.push(langTag);
    if (yearTag) tags.push(yearTag);
    tags.push(hash);  // Always add hash for uniqueness
    
    // Keep original file extension — don't change to .zip/.rar which corrupts files
    const newExt = ext || '.bin';
    
    // Assemble final name
    const parts = [titlePart, ...tags].filter(Boolean);
    let result = parts.join('_');
    
    // If too long, trim
    if (result.length > 80) {
      result = result.slice(0, 80);
    }
    
    // If result is too short, add random filler
    if (result.length < 10) {
      const filler = crypto.randomBytes(4).toString('hex');
      result = `${filler}_${result}`;
    }
    
    return result + newExt;
  }

  // ==================== Public API ====================

  /**
   * Validate the cookie by fetching user info.
   */
  async validate(): Promise<boolean> {
    const MAX_RETRIES = 2;
    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      try {
        // Use drive-pc.quark.cn (Alibaba internal) instead of pan.quark.cn which times out from China servers
        const params = new URLSearchParams({ pr: 'ucpro', fr: 'pc', pdir_fid: '0', _page: '1', _size: '1' });
        const response = await fetch(`${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`, {
          headers: this.getHeaders(),
          signal: AbortSignal.timeout(15000),
        });
        if (!response.ok) return false;
        const data = await response.json() as any;
        if (data?.status === 200) return true;
      } catch (err: any) {
        if (attempt < MAX_RETRIES) {
          console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
          await new Promise(r => setTimeout(r, 2000));
          continue;
        }
        console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
      }
    }
    return false;
  }

  // ==================== Share Save Flow ====================

  /**
   * Save files from a share link → magic rename → create shared link.
   *
   * Flow: token → detail → save → wait_task → rename → share
   */
  async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<{
    success: boolean;
    message: string;
    shareUrl?: string;
    sharePwd?: string;
    folderName?: string;
    taskId?: string;
    renamed?: string[];
    fileCount?: number;
    folderCount?: number;
    originalFolderName?: string;
  }> {
    try {
      // Parse share token from URL
      const urlObj = new URL(shareUrl);
      const pwdId = urlObj.pathname.split('/').filter(Boolean).pop();
      if (!pwdId) {
        return { success: false, message: 'Invalid share URL: could not extract share token' };
      }

      // Step 1: Acquire stoken
      const stoken = await this.acquireStoken(pwdId);
      if (!stoken) {
        return { success: false, message: '😅 Oops！资源好像偷偷溜走了，换个链接试试吧～' };
      }

      // Step 2: Get share detail
      const shareInfo = await this.getShareFiles(pwdId, stoken);
      if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
        return { success: false, message: '🌚 空的！这个分享里啥都没有…' };
      }

      const { files: topFiles, topDir, childFiles } = shareInfo;
      const originalFolderName = topFiles[0]?.file_name || '';
      const fids = topFiles.map(f => f.fid);
      const fidTokens = topFiles.map(f => f.share_fid_token);

      // 按日期创建/查找文件夹，每天的转存存入当天文件夹
      await this.humanDelay();
      const saveDirName = this.dailyFolderName();
      console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
      const saveDirFid = await this.findOrCreateDir(saveDirName);
      const targetPdirFid = saveDirFid || '0';
      if (saveDirFid) {
        console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`);
      } else {
        console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
      }

      // Step 3: Save top-level item(s) to the random directory
      const saveResult = await this.saveFiles(pwdId, stoken, fids, fidTokens, targetPdirFid);
      if (!saveResult.success) {
        return saveResult;
      }

      const taskId = saveResult.taskId!;

      // Step 4: Wait for save task to complete (poll up to 30s)
      const savedFids = await this.waitForTask(taskId, 30000);
      if (!savedFids || savedFids.length === 0) {
        return { success: true, message: '文件已保存，但获取保存结果超时' };
      }

      // Step 5: Magic rename files — with random delay to avoid detection
      await this.humanDelay();
      const renamed: Array<{ original: string; renamed: string }> = [];
      let shareFid = '';
      let savedFolderName = '';
      let newInnerDirName = '';

      if (topDir && childFiles && childFiles.length > 0) {
        // ── Single folder share ──
        const savedDirFid = savedFids[0];
        shareFid = savedDirFid;
        savedFolderName = topFiles[0]?.file_name || '';
      } else {
        // ── Multiple files at top level ──
        shareFid = savedFids[0];
        savedFolderName = topFiles[0]?.file_name || '';
      }

      // Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
      await this.humanDelay();
      let shareUrlResult = '';
      let sharePwdResult = '';
      let shareMsg = '';
      let successCount = 0; // total items (files + folders) actually saved
      if (shareFid) {
        const shareResult = await this.createShareLink(shareFid);
        if (shareResult.success && shareResult.shareUrl) {
          shareUrlResult = shareResult.shareUrl;
          if (shareResult.sharePwd) sharePwdResult = shareResult.sharePwd;
        } else {
          shareMsg = `（分享失败：${shareResult.message}）`;
        }
      }

      // Step 7: Rename files AFTER creating the share link (anti-harmony, won't affect the share)
      if (topDir && childFiles && childFiles.length > 0) {
        // ── Single folder share ──
        const savedDirFid = savedFids[0];

        // List files inside the saved directory
        const dirFiles = await this.listDir(savedDirFid);
        if (dirFiles && dirFiles.length > 0) {
          for (const file of dirFiles) {
            if (file.dir) continue;
            const newName = this.magicRename(file.file_name);
            const renameOk = await this.renameFile(file.fid, newName);
            if (renameOk) {
              renamed.push({ original: file.file_name, renamed: newName });
            }
          }
        }
        
        // Also rename the inner folder itself (the actual shared folder)
        const innerDirOriginalName = sourceTitle || topFiles[0]?.file_name || '';
        if (innerDirOriginalName) {
          newInnerDirName = this.magicRenameDir(innerDirOriginalName);
          const innerDirRenameOk = await this.renameFile(savedDirFid, newInnerDirName);
          if (innerDirRenameOk) {
            console.log(`[Quark] Renamed inner folder: ${innerDirOriginalName} → ${newInnerDirName}`);
          }
        }
      } else {
        // ── Multiple files at top level ──
        for (let i = 0; i < savedFids.length && i < topFiles.length; i++) {
          const originalName = topFiles[i].file_name;
          if (topFiles[i].dir) continue;
          const newName = this.magicRename(originalName);
          const renameOk = await this.renameFile(savedFids[i], newName);
          if (renameOk) {
            renamed.push({ original: originalName, renamed: newName });
          }
        }
      }

      // Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03")
      // DO NOT rename the date folder — it serves as the organizational container.
      // The inner folder (for single-folder shares) was already renamed in Step 7.
      savedFolderName = newInnerDirName ? `${saveDirName}/${newInnerDirName}` : saveDirName;

      // Recursively count files and folders from saved cloud directory
      let fileCount = 0;
      let folderCount = 0;
      if (shareFid) {
        try {
          const counts = await this.countRecursive(shareFid);
          fileCount = counts.fileCount;
          folderCount = counts.folderCount;
        } catch {
          // Fallback to simple count if recursive fails
          console.log('[Quark] Recursive count failed, using fallback');
        }
      }
      // If recursive count returned nothing, try fallback
      if (fileCount === 0 && folderCount === 0) {
        if (topDir && childFiles) {
          folderCount = 1 + childFiles.filter(f => f.dir).length;
          fileCount = childFiles.filter(f => !f.dir).length;
        } else {
          folderCount = topFiles.filter(f => f.dir).length;
          fileCount = topFiles.filter(f => !f.dir).length;
        }
      }

      const renameMsg = renamed.length > 0
        ? `，已重命名 ${renamed.length} 个文件`
        : '';
      const folderMsg = savedFolderName ? `到文件夹「${savedFolderName}」` : '';

      return {
        success: true,
        message: `已保存${folderMsg}${renameMsg}${shareMsg}`,
        shareUrl: shareUrlResult || undefined,
        sharePwd: sharePwdResult || undefined,
        folderName: savedFolderName,
        taskId,
        renamed: renamed.map(r => `${r.original} → ${r.renamed}`),
        fileCount,
        folderCount,
        originalFolderName,
      };
    } catch (err: any) {
      return { success: false, message: err.message || 'Network error' };
    }
  }

  // ==================== Internal API Methods ====================

  /**
   * Create a new directory in the root.
   */
  private async createDir(dirName: string): Promise<string | null> {
    try {
      const resp = await fetch(
        `${this.baseUrl}/1/clouddrive/file?${this.makeQuery()}`,
        {
          method: 'POST',
          headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
          body: JSON.stringify({
            pdir_fid: '0',
            file_name: dirName,
            dir: true,
            dir_path: '',
          }),
          signal: AbortSignal.timeout(10000),
        }
      );
      const data = await resp.json() as any;
      if (data.status === 200 && data.data?.fid) {
        console.log(`[Quark] Created dir "${dirName}" (fid: ${data.data.fid})`);
        return data.data.fid;
      }
      console.log(`[Quark] createDir API returned non-200: status=${data.status} msg=${data.message}`);
      return null;
    } catch (err: any) {
      console.log(`[Quark] createDir error: ${err.message}`);
      return null;
    }
  }

  /**
   * Find an existing directory by name, or create it if not found.
   * Used for daily folders that may already exist from earlier saves today.
   */
  private async findOrCreateDir(dirName: string): Promise<string | null> {
    try {
      // List root directory
      const params = new URLSearchParams({
        pr: 'ucpro', fr: 'pc',
        pdir_fid: '0',
        _page: '1', _size: '200',
        _fetch_total: '1', _fetch_sub_dirs: '0',
        _sort: 'file_type:asc,updated_at:desc',
        fetch_all_file: '1',
        fetch_risk_file_name: '1',
      });
      const resp = await fetch(
        `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`,
        { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), }
      );
      if (resp.ok) {
        const data = await resp.json() as any;
        if (data.status === 200 && data.data?.list) {
          const existing = data.data.list.find((f: any) => f.dir && f.file_name === dirName);
          if (existing?.fid) {
            console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
            return existing.fid;
          }
        }
      }
      console.log(`[Quark] Daily folder "${dirName}" not found, creating...`);
    } catch (err: any) {
      console.log(`[Quark] findOrCreateDir list error: ${err.message}`);
    }
    // Not found → create it
    const fid = await this.createDir(dirName);
    console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`);
    return fid;
  }

  private async acquireStoken(pwdId: string): Promise<string | null> {
    // Retry up to 2 times for transient network issues
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        const params = new URLSearchParams(this.getCommonParams());
        const resp = await fetch(
          `${this.baseUrl}/1/clouddrive/share/sharepage/token?${params.toString()}`,
          {
            method: 'POST',
            headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
            body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
            signal: AbortSignal.timeout(10000),
          }
        );
        if (!resp.ok) {
          if (attempt < 2) continue;
          return null;
        }
        const data = await resp.json() as any;
        if (data.status === 200 && data.data?.stoken) {
          return data.data.stoken;
        }
        return null;
      } catch {
        if (attempt >= 2) return null;
        // Wait before retry (0.5s, 1s)
        await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
      }
    }
    return null;
  }

  /**
   * Recursively collect files from a share.
   * If the share contains a single directory, drill into it to list contents
   * but still save the directory itself.
   */
  private async getShareFiles(pwdId: string, stoken: string): Promise<{ files: ShareFile[]; topDir: boolean; childFiles?: ShareFile[] } | null> {
    try {
      const topLevel = await this.getDetailAt(pwdId, stoken, '0');
      if (!topLevel || topLevel.length === 0) return null;

      // If the share is a single directory, we save the directory itself
      // and fetch its contents for renaming later
      if (topLevel.length === 1 && topLevel[0].dir) {
        const innerFiles = await this.getDetailAt(pwdId, stoken, topLevel[0].fid);
        // Return both: the directory for saving, its contents for renaming
        return {
          files: topLevel,
          topDir: true,
          childFiles: innerFiles || [],
        };
      }

      // Multiple top-level items: save them directly
      return {
        files: topLevel,
        topDir: false,
      };
    } catch {
      return null;
    }
  }

  /**
   * Fetch detail at a given pdir_fid.
   */
  private async getDetailAt(
    pwdId: string, stoken: string, pdirFid: string,
  ): Promise<ShareFile[]> {
    const params = new URLSearchParams({
      ...this.getCommonParams(),
      pwd_id: pwdId,
      stoken,
      pdir_fid: pdirFid,
      force: '0',
      _page: '1',
      _size: '50',
      _fetch_banner: '0',
      _fetch_share: '1',
      _fetch_total: '1',
      _sort: 'file_type:asc,updated_at:desc',
      ver: '2',
      fetch_share_full_path: '0',
    });
    const resp = await fetch(
      `${this.baseUrl}/1/clouddrive/share/sharepage/detail?${params.toString()}`,
      { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), }
    );
    if (!resp.ok) return [];
    const data = await resp.json() as any;
    if (data.status !== 200) return [];
    return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
      fid: f.fid,
      file_name: f.file_name,
      share_fid_token: f.share_fid_token || '',
      dir: f.dir || false,
      size: f.size || 0,
    }));
  }

  private async saveFiles(
    pwdId: string, stoken: string,
    fids: string[], fidTokens: string[],
    toPdirFid: string
  ): Promise<{ success: boolean; message: string; taskId?: string }> {
    try {
      const resp = await fetch(
        `${this.baseUrl}/1/clouddrive/share/sharepage/save?${this.makeQuery()}`,
        {
          method: 'POST',
          headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
          body: JSON.stringify({
            fid_list: fids,
            fid_token_list: fidTokens,
            to_pdir_fid: toPdirFid,
            pwd_id: pwdId,
            stoken,
            pdir_fid: '0',
            scene: 'link',
          }),
          signal: AbortSignal.timeout(30000),
        }
      );
      const data = await resp.json() as any;
      if (data.status === 200 && data.data?.task_id) {
        return { success: true, message: 'Save task created', taskId: data.data.task_id };
      }
      return {
        success: false,
        message: data.message === 'require login [guest]'
          ? '夸克网盘 Cookie 已过期，请在后台重新配置 Cookie'
          : (data.message || `API 返回错误 (status=${data.status}, code=${data.code})`),
      };
    } catch (err: any) {
      return { success: false, message: err.message || 'Network error' };
    }
  }

  /**
   * Poll task status until complete or timeout.
   * Returns the saved file FIDs (save_as_top_fids).
   */
  private async waitForTask(taskId: string, timeoutMs: number): Promise<string[] | null> {
    const start = Date.now();
    let retryIndex = 0;

    while (Date.now() - start < timeoutMs) {
      try {
        const params = new URLSearchParams({
          ...this.getCommonParams(),
          uc_param_str: '',
          task_id: taskId,
          retry_index: String(retryIndex),
          __dt: String(Math.floor(Math.random() * 240000 + 60000)),
          __t: String(Date.now() / 1000),
        });
        const resp = await fetch(
          `${this.baseUrl}/1/clouddrive/task?${params.toString()}`,
          { headers: this.getHeaders(), signal: AbortSignal.timeout(10000), }
        );
        const data = await resp.json() as any;
        if (data.status === 200) {
          if (data.data?.status === 2) {
            // Task completed
            const savedFids: string[] = data.data?.save_as?.save_as_top_fids || [];
            return savedFids;
          }
          // Still in progress
          retryIndex++;
        }
      } catch {
        // Network error, retry
      }
      await new Promise(r => setTimeout(r, 1000));
    }
    return null; // Timeout
  }

  /**
   * Rename a file by its FID.
   */
  private async renameFile(fid: string, newName: string): Promise<boolean> {
    try {
      const resp = await fetch(
        `${this.baseUrl}/1/clouddrive/file/rename?${this.makeQuery()}`,
        {
          method: 'POST',
          headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
          body: JSON.stringify({ fid, file_name: newName }),
          signal: AbortSignal.timeout(10000),
        }
      );
      const data = await resp.json() as any;
      return data.status === 200 || data.code === 0;
    } catch {
      return false;
    }
  }

  /**
   * List files in a directory by its FID (after save to cloud).
   */
  private async listDir(pdirFid: string): Promise<ShareFile[]> {
    try {
      const params = new URLSearchParams({
        ...this.getCommonParams(),
        uc_param_str: '',
        pdir_fid: pdirFid,
        _page: '1',
        _size: '50',
        _fetch_total: '1',
        _fetch_sub_dirs: '0',
        _sort: 'file_type:asc,updated_at:desc',
        fetch_all_file: '1',
        fetch_risk_file_name: '1',
      });
      const resp = await fetch(
        `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`,
        { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), }
      );
      if (!resp.ok) return [];
      const data = await resp.json() as any;
      if (data.status !== 200) return [];
      return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
        fid: f.fid,
        file_name: f.file_name,
        share_fid_token: '',
        dir: f.dir || false,
        size: f.size || 0,
      }));
    } catch {
      return [];
    }
  }

  /**
   * Recursively count files and folders for a saved cloud directory.
   */
  private async countRecursive(pdirFid: string): Promise<{ fileCount: number; folderCount: number }> {
    let fileCount = 0;
    let folderCount = 0;
    const stack = [pdirFid];
    const visited = new Set<string>();
    while (stack.length > 0) {
      const fid = stack.pop()!;
      if (visited.has(fid)) continue;
      visited.add(fid);
      const files = await this.listDir(fid);
      if (!files) continue;
      for (const f of files) {
        if (f.dir) {
          folderCount++;
          stack.push(f.fid);
        } else {
          fileCount++;
        }
      }
    }
    return { fileCount, folderCount };
  }

  /**
   * Create a share link for a file/folder.
   * Flow: create task → poll for share_id → submit to get short URL.
   */
  async createShareLink(fileId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string }> {
    try {
      const sharePwd = this.randomSharePwd();
      
      // Try different share_type values (1=7天, 0=无限制)
      const shareTypes = ['1', '0'];
      let lastError = '';
      
      for (const st of shareTypes) {
        await this.humanDelay();
        // Step 1: Create share task - get task_id
        const response = await fetch(
          `${this.baseUrl}/1/clouddrive/share?${this.makeQuery()}`,
          {
            method: 'POST',
            headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
            body: JSON.stringify({
              fid_list: [fileId],
              share_type: st,
              url_type: '1',
              share_pwd: sharePwd,
            }),
            signal: AbortSignal.timeout(15000),
          }
        );
        const data = await response.json() as any;
        const taskId = data.data?.task_id;
        if (!taskId) {
          lastError = data.message || `share_type=${st} 失败`;
          console.error('[Quark] Create share task failed (type=%s):', st, data.message || JSON.stringify(data).slice(0, 200));
          continue;
        }

        // Step 2: Poll task until complete
        const result = await this.waitForShareTask(taskId, 20000);
        if (!result?.shareId) {
          lastError = result?.message || '任务超时';
          console.error('[Quark] Wait for share task failed (type=%s):', st, result?.message || 'unknown');
          continue;
        }

        // Step 3: Submit share via /password endpoint
        const shareUrl = await this.submitShare(result.shareId, sharePwd);
        if (shareUrl) {
          return {
            success: true,
            shareUrl,
            sharePwd,
            message: `分享链接已生成（密码：${sharePwd}）`,
          };
        }
        lastError = '提交密码后未获取到短链接';
      }

      return { success: false, message: lastError || '🤷 各种姿势都试过了，就是分享不出来…' };
    } catch (err: any) {
      console.error('[Quark] createShareLink error:', err.message);
      return { success: false, message: err.message || '🌩️ 网络开小差了，再试试？' };
    }
  }

  /**
   * Submit share via /password endpoint to get the actual short URL.
   * The initial task API only returns a 32-char UUID, this call converts it
   * to a valid short URL code (e.g. https://pan.quark.cn/s/12chars).
   */
  private async submitShare(shareId: string, sharePwd?: string): Promise<string | null> {
    try {
      const response = await fetch(
        `${this.baseUrl}/1/clouddrive/share/password?${this.makeQuery()}`,
        {
          method: 'POST',
          headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
          body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
          signal: AbortSignal.timeout(15000),
        }
      );
      const data = await response.json() as any;
      if (data.status === 200 && data.data?.share_url) {
        console.log('[Quark] Share short URL:', data.data.share_url);
        return data.data.share_url;
      }
      console.log('[Quark] /password response:', JSON.stringify(data).slice(0, 300));
      console.error('[Quark] /password FAIL status=%s msg=%s', data.status, data.message || '');
      return null;
    } catch (err) {
      console.log('[Quark] /password error:', err);
      return null;
    }
  }

  /**
   * Poll share task until complete and extract share URL/shortcode.
   * Tries multiple approaches to extract the correct short URL code.
   */
  private async waitForShareTask(taskId: string, timeoutMs: number): Promise<{ shareId?: string; message?: string } | null> {
    const start = Date.now();
    let retryIndex = 0;
    while (Date.now() - start < timeoutMs) {
      try {
        const params = new URLSearchParams({
          ...this.getCommonParams(),
          uc_param_str: '',
          task_id: taskId,
          retry_index: String(retryIndex),
          __dt: String(Math.floor(Math.random() * 240000 + 60000)),
          __t: String(Date.now() / 1000),
        });
        const resp = await fetch(
          `${this.baseUrl}/1/clouddrive/task?${params.toString()}`,
          { headers: this.getHeaders(), signal: AbortSignal.timeout(10000), }
        );
        const data = await resp.json() as any;
        if (data.data?.status === 2) {
          // Task completed — try multiple extraction approaches

          // 1. Direct share_url field
          if (data.data?.share_url) {
            const match = data.data.share_url.match(/\/s\/([a-zA-Z0-9]+)/);
            if (match) return { shareId: match[1] };
          }

          // 2. Nested share object
          if (data.data?.share?.url) {
            const match = data.data.share.url.match(/\/s\/([a-zA-Z0-9]+)/);
            if (match) return { shareId: match[1] };
          }
          if (data.data?.share?.short_url) {
            const match = data.data.share.short_url.match(/\/s\/([a-zA-Z0-9]+)/);
            if (match) return { shareId: match[1] };
          }

          // 3. share_id — validate it's a reasonable short code (8-20 chars, not UUID-like)
          const shareId = data.data?.share_id;
          if (shareId && shareId.length <= 20 && shareId.length >= 8) {
            return { shareId };
          }

          // 4. Regex search through the full response for a URL pattern
          const str = JSON.stringify(data);
          const urlMatch = str.match(/https?:\/\/pan\.quark\.cn\/s\/([a-zA-Z0-9]{6,16})/);
          if (urlMatch) {
            return { shareId: urlMatch[1] };
          }

          // 5. Extract from any URL field in the response
          const urlFields = ['url', 'link', 'share_url', 'short_url', 'share_link'];
          for (const field of urlFields) {
            const val = data.data?.[field] || data.data?.share?.[field];
            if (typeof val === 'string' && val.includes('pan.quark.cn/s/')) {
              const m = val.match(/\/s\/([a-zA-Z0-9]+)/);
              if (m) return { shareId: m[1] };
            }
          }

          // 6. Log full share task response for debugging
          console.log('[Quark] Full share task response:', JSON.stringify(data, null, 2).slice(0, 2000));

          // 7. Even if shareId is UUID-like (32 hex chars), use it anyway as last resort
          if (shareId) {
            return { shareId };
          }

          return { message: 'Share task completed but no share URL found' };
        }
        if (data.data?.status === 3) {
          return { message: data.message || 'Share task failed' };
        }
        retryIndex++;
      } catch {
        // Retry
      }
      await new Promise(r => setTimeout(r, 1000));
    }
    return { message: 'Share task timed out' };
  }

  // ==================== Other Features ====================

  async checkIn(): Promise<{ success: boolean; message: string; signedDays?: number }> {
    try {
      const mparam = this.getMparam();
      if (!mparam.kps || !mparam.sign || !mparam.vcode) {
        return { success: false, message: 'Cookie 缺少 kps/sign/vcode 参数，无法签到' };
      }
      const params = new URLSearchParams({
        ...this.getCommonParams(),
        kps: mparam.kps,
        sign: mparam.sign,
        vcode: mparam.vcode,
      });
      const response = await fetch(
        `${this.baseUrl}/1/clouddrive/capacity/growth/sign?${params.toString()}`,
        {
          method: 'POST',
          headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
          body: JSON.stringify({}),
          signal: AbortSignal.timeout(15000),
        }
      );
      const data = await response.json() as any;
      if (data.status === 200) {
        const signInfo = data.data?.sign_in_daily || {};
        const signedDays = signInfo.signed_day || signInfo.sign_day || 0;
        const isSigned = signInfo.sign_in === true || signInfo.is_sign_in === true;
        return {
          success: isSigned,
          message: isSigned
            ? `签到成功！本月已签到 ${signedDays} 天`
            : `签到失败（可能今日已签到，本月 ${signedDays} 天）`,
          signedDays,
        };
      }
      if (data.status === 40002 || data.status === 40003) {
        return { success: false, message: '今日已签到，明天再来吧' };
      }
      return { success: false, message: data.message || `签到失败（${data.status}）` };
    } catch (err: any) {
      return { success: false, message: `签到请求失败：${err.message || '网络错误'}` };
    }
  }

  // Get total capacity from /capacity/detail API
  async getStorageInfoQuick(): Promise<{ total: string; totalBytes: number }> {
    try {
      const mparam = this.getMparam();
      const params = new URLSearchParams({
        ...this.getCommonParams(),
        kps: mparam.kps || '',
        sign: mparam.sign || '',
        vcode: mparam.vcode || '',
      });
      const response = await fetch(`${this.baseUrl}/1/clouddrive/capacity/detail?${params.toString()}`, {
        headers: this.getHeaders(),
        signal: AbortSignal.timeout(10000),
      });
      if (response.ok) {
        const data = await response.json() as any;
        if (data.status === 200 && data.data) {
          let totalBytes = data.data.capacity_summary?.sum_capacity || 0;
          if (totalBytes === 0) {
            const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
            totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
          }
          return { total: this.formatBytes(totalBytes), totalBytes };
        }
      }
    } catch {}
    return { total: '-', totalBytes: 0 };
  }

  async getStorageInfo(): Promise<StorageInfo> {
    try {
      const mparam = this.getMparam();
      let totalBytes = 0;
      const params = new URLSearchParams({
        ...this.getCommonParams(),
        kps: mparam.kps || '',
        sign: mparam.sign || '',
        vcode: mparam.vcode || '',
      });
      const response = await fetch(`${this.baseUrl}/1/clouddrive/capacity/detail?${params.toString()}`, {
        headers: this.getHeaders(),
        signal: AbortSignal.timeout(10000),
      });
      if (response.ok) {
        const data = await response.json() as any;
        if (data.status === 200 && data.data) {
          totalBytes = data.data.capacity_summary?.sum_capacity || 0;
          if (totalBytes === 0) {
            const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
            totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
          }
        }
      }

      // Calculate used space by recursively traversing all files
      const usedBytes = await this.calculateUsedSpace();

      // 即使 totalBytes 为 0（API 异常或免费账户），仍然返回已用空间
      // 免费/过期账户用 expired 中的历史最大容量作为参考
      if (totalBytes > 0 || usedBytes > 0) {
        return {
          total: totalBytes > 0 ? this.formatBytes(totalBytes) : '-',
          used: this.formatBytes(usedBytes),
          usedBytes,
          totalBytes: totalBytes > 0 ? totalBytes : 0,
        };
      }
      return { used: '0 B', total: '-', usedBytes: 0, totalBytes: 0 };
    } catch {
      return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 };
    }
  }

  private formatBytes(bytes: number): string {
    if (bytes === 0) return '0 B';
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(1024));
    return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
  }

  // ==================== Cleanup / Trash ====================

  /**
   * List files at the root level (pdir_fid=0) — returns all top-level dirs/files.
   * Used by cleanup to find date-named folders.
   */
  async listRootDir(): Promise<Array<{ fid: string; file_name: string; dir: boolean; size: number }>> {
    try {
      const params = new URLSearchParams({
        pr: 'ucpro', fr: 'pc',
        pdir_fid: '0',
        _page: '1', _size: '200',
        _fetch_total: '1', _fetch_sub_dirs: '0',
        _sort: 'file_type:asc,updated_at:desc',
        fetch_all_file: '1',
        fetch_risk_file_name: '1',
      });
      const resp = await fetch(
        `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`,
        { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), }
      );
      if (!resp.ok) return [];
      const data = await resp.json() as any;
      if (data.status !== 200 || !data.data?.list) return [];
      return (data.data.list || []).map((f: any) => ({
        fid: f.fid,
        file_name: f.file_name,
        dir: f.dir || false,
        size: f.size || 0,
      }));
    } catch {
      return [];
    }
  }

  /**
   * List all files in a directory, handling pagination.
   * Fetches all pages until no more results.
   */
  private async listDirAllPages(pdirFid: string): Promise<ShareFile[]> {
    const allFiles: ShareFile[] = [];
    let page = 1;
    const pageSize = 100;
    let total = -1;
    while (total === -1 || (page - 1) * pageSize < total) {
      const params = new URLSearchParams({
        ...this.getCommonParams(),
        uc_param_str: '',
        pdir_fid: pdirFid,
        _page: String(page),
        _size: String(pageSize),
        _fetch_total: '1',
        _fetch_sub_dirs: '0',
        _sort: 'file_type:asc,updated_at:desc',
        fetch_all_file: '1',
        fetch_risk_file_name: '1',
      });
      try {
        const resp = await fetch(
          `${this.baseUrl}/1/clouddrive/file/sort?${params.toString()}`,
          { headers: this.getHeaders(), signal: AbortSignal.timeout(15000), }
        );
        if (!resp.ok) break;
        const data = await resp.json() as any;
        if (data.status !== 200 || !data.data?.list) break;
        allFiles.push(...(data.data.list || []).filter((f: any) => f.fid).map((f: any) => ({
          fid: f.fid,
          file_name: f.file_name,
          share_fid_token: '',
          dir: f.dir || false,
          size: f.size || 0,
        })));
        if (total === -1) {
          total = data.metadata?._total || allFiles.length;
        }
        page++;
      } catch {
        break;
      }
    }
    return allFiles;
  }

  /**
   * Calculate total used space by recursively traversing all files
   * and summing their sizes. Uses 3-hour time window cache (0/3/6/9/12/15/18/21).
   */
  async calculateUsedSpace(): Promise<number> {
    // 3-hour time window: refetch only when crossing 0/3/6/9/12/15/18/21 boundaries
    const currentHourBlock = Math.floor(new Date().getHours() / 3);
    if (this.cachedUsedSpace && this.cachedUsedSpace.hourBlock === currentHourBlock) {
      return this.cachedUsedSpace.bytes;
    }
    let totalUsed = 0;
    const stack: string[] = ['0'];
    const visited = new Set<string>();
    while (stack.length > 0) {
      const fid = stack.pop()!;
      if (visited.has(fid)) continue;
      visited.add(fid);
      const files = await this.listDirAllPages(fid);
      if (!files.length) continue;
      for (const f of files) {
        if (f.dir) {
          stack.push(f.fid);
        } else {
          totalUsed += f.size || 0;
        }
      }
      await new Promise(r => setTimeout(r, 50));
    }
    this.cachedUsedSpace = { bytes: totalUsed, hourBlock: currentHourBlock };
    return totalUsed;
  }

  /**
   * Move specified files/folders to trash (recycle bin).
   * Files in trash don't count toward storage usage.
   */
  async trashFiles(fids: string[]): Promise<boolean> {
    if (!fids.length) return true;
    try {
      const response = await fetch(
        `${this.baseUrl}/1/clouddrive/file/trash?${this.makeQuery()}`,
        {
          method: 'POST',
          headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
          body: JSON.stringify({
            action_type: 1,  // 1 = move to trash
            filelist: fids,
            exclude_filelist: [],
          }),
          signal: AbortSignal.timeout(30000),
        }
      );
      if (!response.ok) return false;
      const data = await response.json() as any;
      if (data.status === 200) return true;
      console.error(`[Quark] trashFiles failed: ${data.message || data.status}`);
      return false;
    } catch (err: any) {
      console.error(`[Quark] trashFiles error: ${err.message}`);
      return false;
    }
  }

  /**
   * Empty the recycle bin — permanently delete all files in trash.
   * This actually frees up storage space.
   * Quark API: POST /1/clouddrive/file/trash/clear
   */
  async emptyTrash(): Promise<boolean> {
    try {
      const response = await fetch(
        `${this.baseUrl}/1/clouddrive/file/trash/clear?${this.makeQuery()}`,
        {
          method: 'POST',
          headers: { ...this.getHeaders(), 'Content-Type': 'application/json' },
          body: JSON.stringify({}),
          signal: AbortSignal.timeout(60000),
        }
      );
      if (!response.ok) return false;
      const data = await response.json() as any;
      if (data.status === 200) return true;
      console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`);
      return false;
    } catch (err: any) {
      console.error(`[Quark] emptyTrash error: ${err.message}`);
      return false;
    }
  }

  // ════════════════════════════════════════════════════════════════
  //  Cleanup operations — called by the generic cleanup controller
  //  (cleanup.service.ts). Each cloud driver implements these to
  //  tell the controller HOW to clean, while the controller decides
  //  WHEN and WITH WHAT parameters.
  // ════════════════════════════════════════════════════════════════

  /**
   * [Cleanup] Trash date-named folders (YYYY-MM-DD) older than `days`.
   * Called by the generic cleanup controller with cleanup_file_retention_days.
   * Returns the number of folders trashed.
   */
  async cleanupOldDateFolders(days: number): Promise<{ trashed: number; errors: string[] }> {
    const errors: string[] = [];
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - days);
    const cutoffStr = cutoff.toISOString().slice(0, 10);

    try {
      const rootItems = await this.listRootDir();
      const oldFolders = rootItems.filter(item => {
        if (!item.dir) return false;
        if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
        return item.file_name < cutoffStr;
      });

      if (oldFolders.length === 0) {
        return { trashed: 0, errors: [] };
      }

      const fids = oldFolders.map(f => f.fid);
      console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
      const ok = await this.trashFiles(fids);
      if (ok) {
        return { trashed: fids.length, errors: [] };
      }
      return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] };
    } catch (err: any) {
      return { trashed: 0, errors: [err.message] };
    }
  }

  /**
   * [Cleanup] If used space exceeds thresholdPercent% of total,
   * delete the oldest date folders until totalBytes * deletePercent/100
   * of total capacity is freed. Returns the number of folders trashed.
   */
  async cleanupBySpaceThreshold(
    thresholdPercent: number,
    deletePercent: number,
  ): Promise<{ trashed: number; errors: string[] }> {
    const errors: string[] = [];

    try {
      const storage = await this.getStorageInfo();
      if (storage.totalBytes <= 0) return { trashed: 0, errors: [] };

      const usagePercent = (storage.usedBytes / storage.totalBytes) * 100;
      if (usagePercent < thresholdPercent) {
        console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
        return { trashed: 0, errors: [] };
      }

      const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100);

      const rootItems = await this.listRootDir();
      const dateFolders = rootItems
        .filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
        .sort((a, b) => a.file_name.localeCompare(b.file_name));

      if (dateFolders.length === 0) return { trashed: 0, errors: [] };

      // Accumulate oldest folders until their total size >= targetBytesToFree
      // If API returns size=0 for directories, fall back to count-based estimation
      const hasSizes = dateFolders.some(f => f.size > 0);
      let cumulativeSize = 0;
      const foldersToTrash: typeof dateFolders = [];

      if (hasSizes) {
        for (const folder of dateFolders) {
          foldersToTrash.push(folder);
          cumulativeSize += folder.size;
          if (cumulativeSize >= targetBytesToFree) break;
        }
      } else {
        const avgSizePerFolder = storage.usedBytes / dateFolders.length;
        const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder));
        foldersToTrash.push(...dateFolders.slice(0, estCount));
        cumulativeSize = estCount * avgSizePerFolder;
      }

      const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0);
      const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0);
      const fidsToTrash = foldersToTrash.map(f => f.fid);
      console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes/1024/1024/1024).toFixed(0)} GB total)`);

      const ok = await this.trashFiles(fidsToTrash);
      if (ok) {
        console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`);
        return { trashed: foldersToTrash.length, errors: [] };
      }
      return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] };
    } catch (err: any) {
      return { trashed: 0, errors: [err.message] };
    }
  }
}
