import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { runBasicFileScan } from './mindspace-scan.mjs';
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import {
  assetThumbnailKey,
  ensureHtmlThumbnail,
  scheduleHtmlThumbnail,
} from './mindspace-thumbnails.mjs';
import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs';
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs';
import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';

const ALLOWED_EXTENSIONS = new Map([
  ['.txt', 'text/plain'],
  ['.md', 'text/markdown'],
  ['.csv', 'text/csv'],
  ['.pdf', 'application/pdf'],
  ['.png', 'image/png'],
  ['.jpg', 'image/jpeg'],
  ['.jpeg', 'image/jpeg'],
  ['.webp', 'image/webp'],
  ['.doc', 'application/msword'],
  ['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
  ['.xls', 'application/vnd.ms-excel'],
  ['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
  ['.ppt', 'application/vnd.ms-powerpoint'],
  ['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
  ['.html', 'text/html'],
  ['.htm', 'text/html'],
]);
const MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024;
const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images';

const PUBLIC_IMAGE_EXTENSIONS = new Map([
  ['image/png', '.png'],
  ['image/jpeg', '.jpg'],
  ['image/webp', '.webp'],
]);

function asNumber(value) {
  return Number(value ?? 0);
}

function normalizeFilename(filename) {
  const normalized = String(filename ?? '').normalize('NFKC').trim();
  if (
    !normalized ||
    normalized === '.' ||
    normalized === '..' ||
    normalized.includes('/') ||
    normalized.includes('\\') ||
    normalized.includes('\0') ||
    /[\u0000-\u001f\u007f]/.test(normalized)
  ) {
    throw Object.assign(new Error('文件名无效'), { code: 'invalid_filename' });
  }
  return normalized.slice(0, 255);
}

function expectedMimeType(filename) {
  return ALLOWED_EXTENSIONS.get(path.extname(filename).toLowerCase()) ?? null;
}

function detectMimeType(buffer, filename) {
  const head = buffer.subarray(0, 256).toString('utf8').trimStart().toLowerCase();
  if (head.startsWith('<!doctype html') || head.startsWith('<html')) return 'text/html';
  if (buffer.subarray(0, 5).toString('ascii') === '%PDF-') return 'application/pdf';
  if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
    return 'image/png';
  }
  if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg';
  if (
    buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
    buffer.subarray(8, 12).toString('ascii') === 'WEBP'
  ) {
    return 'image/webp';
  }
  const extensionMime = expectedMimeType(filename);
  if (buffer.subarray(0, 2).toString('ascii') === 'PK') return extensionMime;
  if (extensionMime?.startsWith('text/')) return extensionMime;
  if (['.doc', '.xls', '.ppt'].includes(path.extname(filename).toLowerCase())) {
    return extensionMime;
  }
  return null;
}

function assetTypeForMime(mimeType) {
  if (mimeType.startsWith('image/')) return 'image';
  if (mimeType === 'text/html') return 'html';
  if (mimeType === 'application/pdf') return 'pdf';
  if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'excel';
  if (mimeType.includes('word')) return 'word';
  if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return 'ppt';
  if (mimeType === 'text/markdown') return 'markdown';
  return 'file';
}

function visibilityForCategory(categoryCode) {
  return categoryCode === 'public' ? 'public_candidate' : 'private';
}

function isPublicImageAsset(row) {
  return row?.category_code === 'public' && String(row?.mime_type ?? '').startsWith('image/');
}

function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') {
  const fallbackExtension = path.extname(String(fallbackFilename)).toLowerCase();
  const extension = PUBLIC_IMAGE_EXTENSIONS.get(mimeType) || fallbackExtension || '.img';
  return `${assetId}${extension}`;
}

function publicTempImageStorageKey(userId, assetId, mimeType, fallbackFilename = '') {
  return path.posix.join(
    'users',
    userId,
    PUBLIC_ZONE_DIR,
    PUBLIC_TEMP_IMAGE_DIR,
    publicTempImageFilename(assetId, mimeType, fallbackFilename),
  );
}

function publicTempImageUrl(userId, assetId, mimeType, fallbackFilename = '') {
  const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
  return `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(userId)}/${PUBLIC_ZONE_DIR}/${PUBLIC_TEMP_IMAGE_DIR}/${encodeURIComponent(filename)}`;
}

function assetResponse(row) {
  return {
    id: row.id,
    categoryId: row.category_id,
    categoryCode: row.category_code,
    assetType: row.asset_type,
    mimeType: row.mime_type,
    filename: row.original_filename,
    displayName: row.display_name,
    sizeBytes: asNumber(row.size_bytes),
    checksum: row.checksum,
    riskLevel: row.risk_level,
    visibility: row.visibility,
    status: row.status,
    scanStatus: row.scan_status ?? 'passed',
    sourceType: row.source_type,
    hasThumbnail: Boolean(row.has_thumbnail ?? row.mime_type === 'text/html'),
    publicUrl: isPublicImageAsset(row)
      ? publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename)
      : null,
    sourcePageId: row.source_page_id ?? null,
    createdAt: asNumber(row.created_at),
    updatedAt: asNumber(row.updated_at),
  };
}

function assertAssetDownloadable(asset) {
  if (asset.status === 'quarantined') {
    throw Object.assign(new Error('文件尚未通过安全检查'), { code: 'security_scan_required' });
  }
  if (asset.scan_status === 'blocked') {
    throw Object.assign(new Error('文件存在安全风险，暂不可下载'), { code: 'security_risk_blocked' });
  }
  if (asset.scan_status === 'pending') {
    throw Object.assign(new Error('文件尚未完成安全检查'), { code: 'security_scan_required' });
  }
}

export function validateUploadRequest({ filename, sizeBytes, maxFileBytes = DEFAULT_MAX_FILE_BYTES }) {
  const normalizedFilename = normalizeFilename(filename);
  const normalizedSize = Number(sizeBytes);
  if (!Number.isSafeInteger(normalizedSize) || normalizedSize <= 0) {
    throw Object.assign(new Error('文件不能为空'), { code: 'invalid_file_size' });
  }
  if (normalizedSize > maxFileBytes) {
    throw Object.assign(new Error('文件超过单文件大小限制'), { code: 'file_too_large' });
  }
  const mimeType = expectedMimeType(normalizedFilename);
  if (!mimeType) {
    throw Object.assign(new Error('暂不支持该文件类型'), { code: 'unsupported_file_type' });
  }
  if (mimeType.startsWith('image/') && normalizedSize > MAX_IMAGE_UPLOAD_BYTES) {
    throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
  }
  return { filename: normalizedFilename, sizeBytes: normalizedSize, expectedMimeType: mimeType };
}

export function createAssetService(pool, options = {}) {
  const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
  const h5Root = options.h5Root ? path.resolve(options.h5Root) : null;
  const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES);
  const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1000);
  const idFactory = options.idFactory ?? (() => crypto.randomUUID());
  const workspaceSync = createWorkspaceAssetSync({
    pool,
    storageRoot,
    h5Root,
    maxFileBytes,
    idFactory,
  });

  const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => {
    if (!h5Root) return null;
    return mirrorAssetToZone({
      workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
      categoryCode,
      filename,
      sourcePath,
    });
  };

  const writePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename, sourcePath) => {
    if (!h5Root) return null;
    const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
    const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
    const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename);
    await fs.mkdir(path.dirname(target), { recursive: true });
    await fs.copyFile(sourcePath, target);
    return target;
  };

  const removePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename) => {
    if (!h5Root) return;
    const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
    const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
    const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename);
    await fs.rm(target, { force: true });
  };

  const absoluteStoragePath = (storageKey) => {
    const resolved = path.resolve(storageRoot, storageKey);
    if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
      throw new Error('存储路径越界');
    }
    return resolved;
  };

  const resolveStoragePathCandidates = (storageKey) => {
    const normalized = String(storageKey ?? '').replace(/\\/g, '/');
    const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized;
    const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/);
    if (!match) return [normalized];
    const [, userId, scope, entityId, versionTag] = match;
    return [
      normalized,
      `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`,
      `users/${userId}/${scope}/${entityId}/versions/${versionTag}`,
    ].filter((candidate, index, list) => list.indexOf(candidate) === index);
  };

  const resolveReadableStoragePath = async (storageKey) => {
    let lastError = null;
    for (const candidate of resolveStoragePathCandidates(storageKey)) {
      const absolutePath = absoluteStoragePath(candidate);
      try {
        await fs.stat(absolutePath);
        return absolutePath;
      } catch (error) {
        if (error?.code === 'ENOENT') {
          lastError = error;
          continue;
        }
        throw error;
      }
    }
    throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' });
  };

  const createUpload = async (userId, input) => {
    const validated = validateUploadRequest({ ...input, maxFileBytes });
    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();
      const [categories] = await conn.query(
        `SELECT c.id, c.space_id, c.category_code
         FROM h5_space_categories c
         WHERE c.id = ? AND c.user_id = ?
         LIMIT 1
         FOR UPDATE`,
        [input.categoryId, userId],
      );
      const category = categories[0];
      if (!category) {
        throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' });
      }
      if (!['oa', 'private', 'public'].includes(category.category_code)) {
        throw Object.assign(new Error('该分类不允许直接上传'), {
          code: 'category_not_uploadable',
        });
      }

      const [spaces] = await conn.query(
        `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
         FROM h5_user_spaces
         WHERE id = ? AND user_id = ?
         LIMIT 1
         FOR UPDATE`,
        [category.space_id, userId],
      );
      const space = spaces[0];
      if (!space || space.status !== 'active') {
        throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
      }
      const available =
        asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
      if (available < validated.sizeBytes) {
        throw Object.assign(new Error('剩余空间不足'), {
          code: 'quota_exceeded',
          details: { requiredBytes: validated.sizeBytes, availableBytes: Math.max(0, available) },
        });
      }

      const uploadId = idFactory();
      const now = Date.now();
      const temporaryStorageKey = path.posix.join('users', userId, 'temp', `${uploadId}.upload`);
      await conn.query(
        `UPDATE h5_user_spaces
         SET reserved_bytes = reserved_bytes + ?, updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [validated.sizeBytes, now, space.id, userId],
      );
      await conn.query(
        `INSERT INTO h5_upload_sessions
         (id, user_id, space_id, category_id, filename, expected_size, declared_mime_type,
          reserved_bytes, temporary_storage_key, status, expires_at, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?)`,
        [
          uploadId,
          userId,
          space.id,
          category.id,
          validated.filename,
          validated.sizeBytes,
          input.declaredMimeType || null,
          validated.sizeBytes,
          temporaryStorageKey,
          now + uploadTtlMs,
          now,
        ],
      );
      await conn.commit();
      return {
        id: uploadId,
        filename: validated.filename,
        expectedSize: validated.sizeBytes,
        uploadUrl: `/api/mindspace/v1/uploads/${uploadId}/content`,
        expiresAt: now + uploadTtlMs,
        reservedBytes: validated.sizeBytes,
      };
    } catch (error) {
      await conn.rollback();
      throw error;
    } finally {
      conn.release();
    }
  };

  const writeUploadContent = async (userId, uploadId, buffer) => {
    if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
      throw Object.assign(new Error('上传内容为空'), { code: 'invalid_file_size' });
    }
    const [rows] = await pool.query(
      `SELECT id, filename, expected_size, temporary_storage_key, status, expires_at
       FROM h5_upload_sessions
       WHERE id = ? AND user_id = ?
       LIMIT 1`,
      [uploadId, userId],
    );
    const upload = rows[0];
    if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
    if (upload.status !== 'reserved') {
      throw Object.assign(new Error('上传会话状态无效'), { code: 'invalid_upload_state' });
    }
    if (asNumber(upload.expires_at) <= Date.now()) {
      throw Object.assign(new Error('上传会话已过期'), { code: 'upload_expired' });
    }
    if (buffer.length !== asNumber(upload.expected_size) || buffer.length > maxFileBytes) {
      throw Object.assign(new Error('上传内容大小与预期不一致'), {
        code: 'file_size_mismatch',
      });
    }
    const detectedMimeType = detectMimeType(buffer, upload.filename);
    if (!detectedMimeType) {
      throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' });
    }
    if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
      throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
    }

    const target = absoluteStoragePath(upload.temporary_storage_key);
    await fs.mkdir(path.dirname(target), { recursive: true });
    await fs.writeFile(target, buffer, { flag: 'wx' }).catch(async (error) => {
      if (error?.code !== 'EEXIST') throw error;
      await fs.writeFile(target, buffer);
    });
    const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
    await pool.query(
      `UPDATE h5_upload_sessions
       SET actual_size = ?, detected_mime_type = ?, checksum = ?, status = 'uploaded'
       WHERE id = ? AND user_id = ? AND status = 'reserved'`,
      [buffer.length, detectedMimeType, checksum, uploadId, userId],
    );
    return { sizeBytes: buffer.length, mimeType: detectedMimeType, checksum };
  };

  const completeUpload = async (userId, uploadId) => {
  const conn = await pool.getConnection();
  let temporaryPath;
  let finalPath;
  let publicMirror = null;
    try {
      await conn.beginTransaction();
      const [rows] = await conn.query(
        `SELECT u.*, c.category_code
         FROM h5_upload_sessions u
         JOIN h5_space_categories c ON c.id = u.category_id AND c.user_id = u.user_id
         WHERE u.id = ? AND u.user_id = ?
         LIMIT 1
         FOR UPDATE`,
        [uploadId, userId],
      );
      const upload = rows[0];
      if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
      if (upload.status === 'completed' && upload.completed_asset_id) {
        const [existing] = await conn.query(
          `SELECT a.*, c.category_code
           FROM h5_assets a
           JOIN h5_space_categories c ON c.id = a.category_id
           WHERE a.id = ? AND a.user_id = ?`,
          [upload.completed_asset_id, userId],
        );
        await conn.commit();
        return existing[0] ? assetResponse(existing[0]) : null;
      }
      if (upload.status !== 'uploaded') {
        throw Object.assign(new Error('文件内容尚未上传'), { code: 'invalid_upload_state' });
      }
      if (
        asNumber(upload.actual_size) !== asNumber(upload.expected_size) ||
        !upload.detected_mime_type ||
        !upload.checksum
      ) {
        throw Object.assign(new Error('上传内容不完整'), { code: 'file_size_mismatch' });
      }

      const assetId = idFactory();
      const versionId = idFactory();
      temporaryPath = absoluteStoragePath(upload.temporary_storage_key);
      const fileBuffer = await fs.readFile(temporaryPath);
      const scan = runBasicFileScan(fileBuffer, {
        filename: upload.filename,
        mimeType: upload.detected_mime_type,
      });
      const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
      const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
      const shouldPublishTempImage =
        upload.category_code === 'public' &&
        upload.detected_mime_type.startsWith('image/') &&
        scan.scanStatus === 'passed';
      const finalStorageKey = shouldPublishTempImage
        ? publicTempImageStorageKey(userId, assetId, upload.detected_mime_type, upload.filename)
        : upload.temporary_storage_key;
      finalPath = absoluteStoragePath(finalStorageKey);
      if (finalStorageKey !== upload.temporary_storage_key) {
        await fs.mkdir(path.dirname(finalPath), { recursive: true });
        await fs.rename(temporaryPath, finalPath);
      }
      if (shouldPublishTempImage) {
        publicMirror = await writePublicTempImageMirror(
          userId,
          assetId,
          upload.detected_mime_type,
          upload.filename,
          finalPath,
        );
      }

      const now = Date.now();
      const visibility = visibilityForCategory(upload.category_code);
      await conn.query(
        `INSERT INTO h5_assets
         (id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
          display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
          status, source_type, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
        [
          assetId,
          userId,
          upload.space_id,
          upload.category_id,
          assetTypeForMime(upload.detected_mime_type),
          upload.detected_mime_type,
          upload.filename,
          upload.filename,
          versionId,
          upload.actual_size,
          upload.checksum,
          scan.riskLevel,
          visibility,
          assetStatus,
          now,
          now,
        ],
      );
      await conn.query(
        `INSERT INTO h5_asset_versions
         (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
          created_by, change_note, scan_status, created_at)
         VALUES (?, ?, 1, ?, ?, ?, ?, ?, '初始上传', ?, ?)`,
        [
          versionId,
          assetId,
          finalStorageKey,
          upload.actual_size,
          upload.checksum,
          upload.detected_mime_type,
          userId,
          versionScanStatus,
          now,
        ],
      );
      await conn.query(
        `UPDATE h5_user_spaces
         SET reserved_bytes = GREATEST(0, reserved_bytes - ?),
             used_bytes = used_bytes + ?,
             updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [upload.reserved_bytes, upload.actual_size, now, upload.space_id, userId],
      );
      await conn.query(
        `UPDATE h5_upload_sessions
         SET status = 'completed', completed_at = ?, completed_asset_id = ?
         WHERE id = ? AND user_id = ?`,
        [now, assetId, uploadId, userId],
      );
      await conn.commit();
      return {
        id: assetId,
        user_id: userId,
        categoryId: upload.category_id,
        categoryCode: upload.category_code,
        assetType: assetTypeForMime(upload.detected_mime_type),
        mimeType: upload.detected_mime_type,
        filename: upload.filename,
        displayName: upload.filename,
        sizeBytes: asNumber(upload.actual_size),
        checksum: upload.checksum,
        riskLevel: scan.riskLevel,
        visibility,
        status: assetStatus,
        scanStatus: versionScanStatus,
        sourceType: 'upload',
        createdAt: now,
        updatedAt: now,
        publicUrl: shouldPublishTempImage
          ? publicTempImageUrl(userId, assetId, upload.detected_mime_type, upload.filename)
          : null,
      };
    } catch (error) {
      await conn.rollback();
      if (finalPath && finalPath !== temporaryPath) await fs.rm(finalPath, { force: true }).catch(() => {});
      if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {});
      throw error;
    } finally {
      conn.release();
    }
  };

  const cancelUpload = async (userId, uploadId) => {
    const conn = await pool.getConnection();
    let storageKey;
    try {
      await conn.beginTransaction();
      const [rows] = await conn.query(
        `SELECT id, space_id, reserved_bytes, temporary_storage_key, status
         FROM h5_upload_sessions
         WHERE id = ? AND user_id = ?
         LIMIT 1
         FOR UPDATE`,
        [uploadId, userId],
      );
      const upload = rows[0];
      if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
      if (['completed', 'cancelled', 'expired'].includes(upload.status)) {
        await conn.commit();
        return { cancelled: upload.status !== 'completed' };
      }
      storageKey = upload.temporary_storage_key;
      const now = Date.now();
      await conn.query(
        `UPDATE h5_user_spaces
         SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [upload.reserved_bytes, now, upload.space_id, userId],
      );
      await conn.query(
        `UPDATE h5_upload_sessions SET status = 'cancelled' WHERE id = ? AND user_id = ?`,
        [uploadId, userId],
      );
      await conn.commit();
      if (storageKey) await fs.rm(absoluteStoragePath(storageKey), { force: true });
      return { cancelled: true };
    } catch (error) {
      await conn.rollback();
      throw error;
    } finally {
      conn.release();
    }
  };

  const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => {
    if (syncWorkspace && categoryCode && ['oa', 'private', 'public'].includes(categoryCode)) {
      await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => {});
    }
    const filters = [`a.user_id = ?`, `a.status <> 'deleted'`];
    const params = [userId];
    if (categoryId) {
      filters.push(`a.category_id = ?`);
      params.push(categoryId);
    }
    if (categoryCode) {
      filters.push(`c.category_code = ?`);
      params.push(categoryCode);
    }
    const [rows] = await pool.query(
      `SELECT a.*, c.category_code,
              ANY_VALUE(p.id) AS source_page_id
       FROM h5_assets a
       JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
       LEFT JOIN h5_page_versions pv ON pv.bundle_asset_id = a.id
       LEFT JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted' AND p.user_id = a.user_id
       WHERE ${filters.join(' AND ')}
       GROUP BY a.id
       ORDER BY a.updated_at DESC
       LIMIT 100`,
      params,
    );
    return rows.map(assetResponse);
  };

  const deleteAsset = async (userId, assetId) => {
    const conn = await pool.getConnection();
    let mirrorCleanup = null;
    let storageCleanup = null;
    try {
      await conn.beginTransaction();
      const [rows] = await conn.query(
        `SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, a.mime_type,
                c.category_code, v.storage_key
         FROM h5_assets a
         JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
         LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id
         WHERE a.id = ? AND a.user_id = ?
         LIMIT 1
         FOR UPDATE`,
        [assetId, userId],
      );
      const asset = rows[0];
      if (!asset || asset.status === 'deleted') {
        throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' });
      }
      const [pageReferences] = await conn.query(
        `SELECT p.id, p.title, p.status,
                EXISTS(
                  SELECT 1 FROM h5_publish_records pr
                  WHERE pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
                ) AS published_online
         FROM h5_page_versions pv
         JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted'
         WHERE pv.content_asset_id = ? OR pv.bundle_asset_id = ?
         GROUP BY p.id, p.title, p.status
         LIMIT 10`,
        [assetId, assetId],
      );
      if (pageReferences.length > 0) {
        throw Object.assign(new Error('资产正在被页面使用，不能删除'), {
          code: 'asset_in_use',
          details: {
            hint: '请打开关联页面并删除；删除页面会自动下线公开链接并删除页面内容资产，之后即可删除原始资料。',
            references: pageReferences.map((page) => ({
              type: 'page',
              id: page.id,
              title: page.title,
              status: page.status,
              publishedOnline: Boolean(page.published_online),
            })),
          },
        });
      }
      const now = Date.now();
      await conn.query(
        `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [now, now, assetId, userId],
      );
      await conn.query(
        `UPDATE h5_user_spaces
         SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [asset.size_bytes, now, asset.space_id, userId],
      );
      await conn.commit();
      if (h5Root) {
        mirrorCleanup = {
          id: asset.id,
          categoryCode: asset.category_code,
          filename: asset.original_filename,
          mimeType: asset.mime_type,
        };
      }
      if (asset.storage_key) storageCleanup = asset.storage_key;
    } catch (error) {
      await conn.rollback();
      throw error;
    } finally {
      conn.release();
    }
    if (mirrorCleanup) {
      try {
        removeZoneMirror({
          workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
          categoryCode: mirrorCleanup.categoryCode,
          filename: mirrorCleanup.filename,
        });
        if (/\.html$/i.test(mirrorCleanup.filename)) {
          const thumbName = mirrorCleanup.filename.replace(/\.html$/i, '.thumbnail.svg');
          removeZoneMirror({
            workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
            categoryCode: mirrorCleanup.categoryCode,
            filename: thumbName,
          });
        }
        if (mirrorCleanup.categoryCode === 'public' && mirrorCleanup.mimeType?.startsWith?.('image/')) {
          await removePublicTempImageMirror(
            userId,
            mirrorCleanup.id,
            mirrorCleanup.mimeType,
            mirrorCleanup.filename,
          );
        }
      } catch {
        // Best-effort workspace mirror cleanup; DB delete already committed.
      }
    }
    if (storageCleanup) {
      try {
        await fs.rm(absoluteStoragePath(storageCleanup), { force: true });
      } catch {
        // Best-effort physical storage cleanup; DB delete already committed.
      }
    }
    return { deleted: true };
  };

  const readAsset = async (userId, assetId) => {
    const [rows] = await pool.query(
      `SELECT a.*, c.category_code, v.storage_key, v.scan_status
       FROM h5_assets a
       JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
       JOIN h5_asset_versions v ON v.id = a.current_version_id
       WHERE a.id = ? AND a.user_id = ? AND a.status <> 'deleted'
       LIMIT 1`,
      [assetId, userId],
    );
    const asset = rows[0];
    if (!asset) throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' });
    assertAssetDownloadable(asset);
    return {
      asset: assetResponse(asset),
      path: await resolveReadableStoragePath(asset.storage_key),
    };
  };

  const createChatAsset = async (
    userId,
    { categoryCode, buffer, filename, displayName, sourceType = 'chat' },
  ) => {
    if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
      throw Object.assign(new Error('资产内容为空'), { code: 'invalid_file_size' });
    }
    if (buffer.length > maxFileBytes) {
      throw Object.assign(new Error('文件超过单文件大小限制'), { code: 'file_too_large' });
    }
    const normalizedFilename = normalizeFilename(filename);
    const detectedMimeType = detectMimeType(buffer, normalizedFilename);
    if (!detectedMimeType) {
      throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' });
    }
    if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
      throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
    }
    const conn = await pool.getConnection();
    let finalPath;
    let publicMirror = null;
    try {
      await conn.beginTransaction();
      const [categories] = await conn.query(
        `SELECT c.id, c.space_id, c.category_code
         FROM h5_space_categories c
         WHERE c.user_id = ? AND c.category_code = ?
         LIMIT 1
         FOR UPDATE`,
        [userId, categoryCode],
      );
      const category = categories[0];
      if (!category) {
        throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' });
      }
      if (!['oa', 'private', 'public'].includes(category.category_code)) {
        throw Object.assign(new Error('该分类不允许保存聊天资产'), {
          code: 'category_not_uploadable',
        });
      }
      const [spaces] = await conn.query(
        `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
         FROM h5_user_spaces
         WHERE id = ? AND user_id = ?
         LIMIT 1
         FOR UPDATE`,
        [category.space_id, userId],
      );
      const space = spaces[0];
      if (!space || space.status !== 'active') {
        throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
      }
      const available =
        asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
      if (available < buffer.length) {
        throw Object.assign(new Error('剩余空间不足'), {
          code: 'quota_exceeded',
          details: { requiredBytes: buffer.length, availableBytes: Math.max(0, available) },
        });
      }

      const assetId = idFactory();
      const versionId = idFactory();
      const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
      const scan = runBasicFileScan(buffer, {
        filename: normalizedFilename,
        mimeType: detectedMimeType,
      });
      const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
      const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
      const shouldPublishTempImage =
        category.category_code === 'public' &&
        detectedMimeType.startsWith('image/') &&
        scan.scanStatus === 'passed';
      const finalStorageKey = shouldPublishTempImage
        ? publicTempImageStorageKey(userId, assetId, detectedMimeType, normalizedFilename)
        : path.posix.join('users', userId, 'assets', assetId, 'versions', versionId);
      finalPath = absoluteStoragePath(finalStorageKey);
      await fs.mkdir(path.dirname(finalPath), { recursive: true });
      await fs.writeFile(finalPath, buffer, { flag: 'wx' });
      if (shouldPublishTempImage) {
        publicMirror = await writePublicTempImageMirror(
          userId,
          assetId,
          detectedMimeType,
          normalizedFilename,
          finalPath,
        );
      }
      const now = Date.now();
      const visibility = visibilityForCategory(category.category_code);
      await conn.query(
        `INSERT INTO h5_assets
         (id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
          display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
          status, source_type, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        [
          assetId,
          userId,
          category.space_id,
          category.id,
          assetTypeForMime(detectedMimeType),
          detectedMimeType,
          normalizedFilename,
          displayName || normalizedFilename,
          versionId,
          buffer.length,
          checksum,
          scan.riskLevel,
          visibility,
          assetStatus,
          sourceType,
          now,
          now,
        ],
      );
      await conn.query(
        `INSERT INTO h5_asset_versions
         (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
          created_by, change_note, scan_status, created_at)
         VALUES (?, ?, 1, ?, ?, ?, ?, ?, '从聊天保存', ?, ?)`,
        [
          versionId,
          assetId,
          finalStorageKey,
          buffer.length,
          checksum,
          detectedMimeType,
          userId,
          versionScanStatus,
          now,
        ],
      );
      await conn.query(
        `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [buffer.length, now, category.space_id, userId],
      );
      await conn.commit();
      if (detectedMimeType === 'text/html') {
        scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString('utf8'), {
          title: displayName || normalizedFilename,
          subtitle: category.category_code.toUpperCase(),
          contentStorageKey: finalStorageKey,
        });
      }
      return assetResponse({
        id: assetId,
        user_id: userId,
        category_id: category.id,
        category_code: category.category_code,
        asset_type: assetTypeForMime(detectedMimeType),
        mime_type: detectedMimeType,
        original_filename: normalizedFilename,
        display_name: displayName || normalizedFilename,
        size_bytes: buffer.length,
        checksum,
        risk_level: scan.riskLevel,
        visibility,
        status: assetStatus,
        scan_status: versionScanStatus,
        source_type: sourceType,
        created_at: now,
        updated_at: now,
        has_thumbnail: detectedMimeType === 'text/html',
      });
    } catch (error) {
      await conn.rollback();
      if (finalPath) await fs.rm(finalPath, { force: true }).catch(() => {});
      if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {});
      throw error;
    } finally {
      conn.release();
    }
  };

  const renderAssetThumbnail = async (userId, assetId) => {
    const { asset, path: assetPath } = await readAsset(userId, assetId);
    if (asset.mimeType !== 'text/html') {
      throw Object.assign(new Error('该资产不支持缩略图'), { code: 'thumbnail_not_supported' });
    }
    const [versions] = await pool.query(
      `SELECT storage_key FROM h5_asset_versions
       WHERE asset_id = ? AND version_no = 1
       LIMIT 1`,
      [assetId],
    );
    const html = await fs.readFile(assetPath, 'utf8');
    return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, {
      title: asset.displayName,
      subtitle: asset.categoryCode?.toUpperCase?.() ?? 'HTML',
      contentStorageKey: versions[0]?.storage_key,
    });
  };

  const renderAssetPreview = async (userId, assetId, { downloadPath = null } = {}) => {
    const { asset, path: assetPath } = await readAsset(userId, assetId);
    if (!canPreviewAsset(asset.mimeType)) {
      throw Object.assign(new Error('该资产不支持预览'), { code: 'preview_not_supported' });
    }
    const buffer = await fs.readFile(assetPath);
    const downloadUrl =
      downloadPath ??
      `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`;
    return renderAssetPreviewHtml({ asset, buffer, downloadUrl });
  };

  const extractAssetText = async (userId, assetId) => {
    const { asset, path: assetPath } = await readAsset(userId, assetId);
    const buffer = await fs.readFile(assetPath);
    const extracted = extractAttachmentText(buffer, asset.mimeType, asset.filename);
    return {
      asset: assetResponse(asset),
      ...extracted,
      charCount: extracted.text.length,
    };
  };

  const expireStaleUploads = async (now = Date.now()) => {
    const [rows] = await pool.query(
      `SELECT id, user_id, space_id, reserved_bytes, temporary_storage_key, status
       FROM h5_upload_sessions
       WHERE status IN ('reserved', 'uploaded') AND expires_at <= ?
       LIMIT 200`,
      [now],
    );
    let expired = 0;
    for (const upload of rows) {
      const conn = await pool.getConnection();
      try {
        await conn.beginTransaction();
        const [locked] = await conn.query(
          `SELECT id, reserved_bytes, temporary_storage_key, status
           FROM h5_upload_sessions
           WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded')
           LIMIT 1
           FOR UPDATE`,
          [upload.id, upload.user_id],
        );
        const row = locked[0];
        if (!row) {
          await conn.commit();
          continue;
        }
        await conn.query(
          `UPDATE h5_user_spaces
           SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
           WHERE id = ? AND user_id = ?`,
          [row.reserved_bytes, now, upload.space_id, upload.user_id],
        );
        await conn.query(
          `UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`,
          [upload.id, upload.user_id],
        );
        await conn.commit();
        if (row.temporary_storage_key) {
          await fs.rm(absoluteStoragePath(row.temporary_storage_key), { force: true });
        }
        expired += 1;
      } catch {
        await conn.rollback();
      } finally {
        conn.release();
      }
    }
    return expired;
  };

  return {
    storageRoot,
    createUpload,
    writeUploadContent,
    completeUpload,
    cancelUpload,
    createChatAsset,
    listAssets,
    deleteAsset,
    readAsset,
    renderAssetPreview,
    extractAssetText,
    renderAssetThumbnail,
    syncWorkspaceAssets: workspaceSync.syncUserWorkspace,
    expireStaleUploads,
  };
}

export const assetInternals = {
  detectMimeType,
  normalizeFilename,
  expectedMimeType,
  assetTypeForMime,
};
