import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { redactContent, scanContent } from './mindspace-content-scan.mjs';
import {
  ensurePageThumbnail,
  extractCoverSignals,
  buildFeedThumbnailSvg,
  generateHtmlThumbnail,
  pageThumbnailKey,
  writeThumbnail,
  bufferToImageDataUri,
} from './mindspace-thumbnails.mjs';
import { resolvePublishDir } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';

const MAX_TITLE_LENGTH = 255;
const MAX_SUMMARY_LENGTH = 1000;
const MAX_CONTENT_BYTES = 1024 * 1024;
const TEMPLATE_IDS = new Set(['editorial', 'report', 'profile', 'knowledge-card', 'static-html']);
const SAVE_CATEGORY_CODES = new Set(['draft', 'oa', 'private', 'public']);
const PRIVATE_ASSET_URL_PATTERN =
  /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;

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

function pageError(message, code, details) {
  return Object.assign(new Error(message), { code, details });
}

function normalizeText(value, maxLength, fieldName) {
  const text = String(value ?? '').normalize('NFKC').trim();
  if (!text) throw pageError(`${fieldName}不能为空`, 'invalid_page_input');
  if (text.length > maxLength) {
    throw pageError(`${fieldName}超过长度限制`, 'invalid_page_input');
  }
  return text;
}

function normalizePageInput(input) {
  const title = normalizeText(input.title, MAX_TITLE_LENGTH, '标题');
  const content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容');
  const contentBytes = Buffer.byteLength(content, 'utf8');
  if (contentBytes > MAX_CONTENT_BYTES) {
    throw pageError('页面内容超过 1 MB 限制', 'page_content_too_large');
  }
  const summary = String(input.summary ?? '').normalize('NFKC').trim().slice(0, MAX_SUMMARY_LENGTH);
  const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown';
  const templateId =
    contentFormat === 'html'
      ? 'static-html'
      : TEMPLATE_IDS.has(input.templateId)
        ? input.templateId
        : 'editorial';
  const plainSummary =
    contentFormat === 'html'
      ? content
          .replace(/<script[\s\S]*?<\/script>/gi, ' ')
          .replace(/<style[\s\S]*?<\/style>/gi, ' ')
          .replace(/<[^>]+>/g, ' ')
          .replace(/\s+/g, ' ')
          .trim()
          .slice(0, 180)
      : content.replace(/\s+/g, ' ').slice(0, 180);
  return {
    title,
    summary: summary || plainSummary,
    content,
    contentBytes,
    templateId,
    contentFormat,
    pageType: contentFormat === 'html' ? 'html' : input.pageType || 'article',
    categoryCode: SAVE_CATEGORY_CODES.has(input.categoryCode) ? input.categoryCode : 'draft',
  };
}

function extensionForMime(mimeType, filename = '') {
  const ext = path.extname(String(filename ?? '')).replace(/^\./, '').toLowerCase();
  if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === 'jpeg' ? 'jpg' : ext;
  if (mimeType === 'image/jpeg') return 'jpg';
  if (mimeType === 'image/png') return 'png';
  if (mimeType === 'image/webp') return 'webp';
  if (mimeType === 'image/gif') return 'gif';
  return 'bin';
}

function pageResponse(row) {
  return {
    id: row.id,
    categoryId: row.category_id,
    categoryCode: row.category_code,
    sourceSessionId: row.source_session_id,
    sourceMessageId: row.source_message_id,
    sourceAssetId: row.source_asset_id,
    title: row.title,
    summary: row.summary ?? '',
    pageType: row.page_type,
    contentFormat: row.page_type === 'html' ? 'html' : 'markdown',
    hasThumbnail: row.page_type === 'html',
    templateId: row.template_id,
    status: row.status,
    visibility: row.visibility,
    publicationAccessMode: row.pub_access_mode ?? null,
    publicationUrl: row.pub_public_url ?? null,
    currentVersionId: row.current_version_id,
    versionNo: asNumber(row.version_no),
    content: row.content,
    createdAt: asNumber(row.created_at),
    updatedAt: asNumber(row.updated_at),
  };
}

function escapeHtml(value) {
  return String(value)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#039;');
}

function renderContent(content) {
  return content
    .split(/\n{2,}/)
    .map((block) => {
      const trimmed = block.trim();
      if (!trimmed) return '';
      if (trimmed.startsWith('### ')) return `<h3>${escapeHtml(trimmed.slice(4))}</h3>`;
      if (trimmed.startsWith('## ')) return `<h2>${escapeHtml(trimmed.slice(3))}</h2>`;
      if (trimmed.startsWith('# ')) return `<h1>${escapeHtml(trimmed.slice(2))}</h1>`;
      return `<p>${escapeHtml(trimmed).replaceAll('\n', '<br>')}</p>`;
    })
    .join('\n');
}

function renderPreviewHtml(page) {
  const templateClass = TEMPLATE_IDS.has(page.templateId) ? page.templateId : 'editorial';
  return `<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'">
  <title>${escapeHtml(page.title)}</title>
  <style>
    :root { color: #18211d; background: #f5f0e5; font-family: Georgia, "Times New Roman", serif; }
    * { box-sizing: border-box; }
    body { margin: 0; padding: clamp(24px, 7vw, 88px); background: radial-gradient(circle at 90% 0, #d8e8df, transparent 34rem), #f5f0e5; }
    article { width: min(820px, 100%); margin: 0 auto; padding: clamp(28px, 6vw, 72px); border: 1px solid #d7d0c1; border-radius: 28px; background: rgba(255,253,247,.94); box-shadow: 0 24px 70px rgba(45,53,47,.12); }
    .label { color: #9b6518; font: 800 12px/1.4 ui-sans-serif, sans-serif; letter-spacing: .18em; }
    h1 { margin: 12px 0 18px; font-size: clamp(40px, 8vw, 76px); line-height: 1; letter-spacing: -.045em; }
    .summary { color: #5c655f; font: 18px/1.7 ui-sans-serif, sans-serif; }
    .content { margin-top: 42px; font-size: 20px; line-height: 1.85; }
    .content h1, .content h2, .content h3 { margin-top: 1.4em; font-size: 1.55em; letter-spacing: -.02em; }
    .content p { margin: 0 0 1.2em; }
    .report article { border-top: 8px solid #2f6f57; }
    .knowledge-card article { max-width: 680px; background: linear-gradient(145deg, #fffaf0, #e4eddf); }
    @media (max-width: 560px) { body { padding: 12px; } article { border-radius: 20px; } .content { font-size: 17px; } }
  </style>
</head>
<body class="${templateClass}">
  <article>
    <div class="label">MINDSPACE DRAFT · V${page.versionNo}</div>
    <h1>${escapeHtml(page.title)}</h1>
    <div class="summary">${escapeHtml(page.summary)}</div>
    <div class="content">${renderContent(page.content)}</div>
  </article>
</body>
</html>`;
}

function renderHtmlPreview(html) {
  const csp =
    '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\' https:; img-src data: https:; font-src https: data:; base-uri \'none\'; form-action \'none\'; script-src \'none\'">';
  const previewShell =
    '<style id="mindspace-preview-shell">html,body{margin:0;padding:0;background:#f5f0e5}body{min-height:100%}</style>';
  const source = String(html);
  if (/<head[^>]*>/i.test(source)) {
    return source.replace(/<head([^>]*)>/i, `<head$1>${csp}${previewShell}`);
  }
  if (/<html[^>]*>/i.test(source)) {
    return source.replace(/<html([^>]*)>/i, `<html$1><head>${csp}${previewShell}</head>`);
  }
  return `<!doctype html><html lang="zh-CN"><head>${csp}${previewShell}</head><body>${source}</body></html>`;
}

export function previewContentSecurityPolicy(contentFormat) {
  if (contentFormat === 'html') {
    return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; script-src 'none'";
  }
  return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
}

function isHtmlPage(page) {
  return page.pageType === 'html' || page.page_type === 'html' || page.contentFormat === 'html';
}

function renderPublicationHtml(page) {
  if (isHtmlPage(page)) {
    return renderHtmlPreview(page.content);
  }
  return renderPreviewHtml({
    title: page.title,
    summary: page.summary ?? '',
    templateId: page.template_id,
    versionNo: page.version_no,
    content: page.content,
  }).replace(
    `MINDSPACE DRAFT · V${page.version_no}`,
    `MINDSPACE · V${page.version_no}`,
  );
}

export function createPageService(pool, options = {}) {
  const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
  const h5Root = options.h5Root ? path.resolve(options.h5Root) : null;
  const idFactory = options.idFactory ?? (() => crypto.randomUUID());

  const resolveWorkspacePublishDir = async (userId) => {
    if (!h5Root) return null;
    const [rows] = await pool.query(
      `SELECT username FROM h5_users WHERE id = ? LIMIT 1`,
      [userId],
    );
    const username = rows[0]?.username;
    if (!username) return null;
    return resolvePublishDir(h5Root, { id: userId });
  };

  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 writeVersionContent = async (userId, assetId, versionId, content) => {
    const storageKey = path.posix.join(
      'users',
      userId,
      'pages',
      assetId,
      'versions',
      `${versionId}.md`,
    );
    const target = absoluteStoragePath(storageKey);
    await fs.mkdir(path.dirname(target), { recursive: true });
    await fs.writeFile(target, content, { flag: 'wx' });
    return { storageKey, target };
  };

  const createVersion = async (userId, input, source = {}) => {
    let pageInput = input;
    if (input.pageId && input.contentFormat == null) {
      const [existingPages] = await pool.query(
        `SELECT page_type FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1`,
        [input.pageId, userId],
      );
      if (existingPages[0]?.page_type === 'html') {
        pageInput = { ...input, contentFormat: 'html' };
      }
    }
    const normalized = normalizePageInput(pageInput);
    const conn = await pool.getConnection();
    let writtenPath;
    try {
      await conn.beginTransaction();
      const [spaces] = await conn.query(
        `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
         FROM h5_user_spaces WHERE user_id = ? LIMIT 1 FOR UPDATE`,
        [userId],
      );
      const space = spaces[0];
      if (!space || space.status !== 'active') {
        throw pageError('用户空间不可用', 'space_unavailable');
      }
      const available =
        asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
      if (available < normalized.contentBytes) {
        throw pageError('剩余空间不足', 'quota_exceeded', {
          requiredBytes: normalized.contentBytes,
          availableBytes: Math.max(0, available),
        });
      }
      const [categories] = await conn.query(
        `SELECT id, category_code FROM h5_space_categories
         WHERE space_id = ? AND user_id = ? AND category_code = ?
         LIMIT 1 FOR UPDATE`,
        [space.id, userId, normalized.categoryCode],
      );
      const category = categories[0];
      if (!category) throw pageError('目标分类不存在', 'category_not_found');
      if (normalized.categoryCode !== 'draft') {
        throw pageError('页面记录只能保存在页面草稿区', 'category_not_pageable');
      }

      let pageId = input.pageId;
      let nextVersionNo = 1;
      if (pageId) {
        const [pages] = await conn.query(
          `SELECT p.id, p.current_version_id, pv.version_no
           FROM h5_page_records p
           LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
           WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
           LIMIT 1 FOR UPDATE`,
          [pageId, userId],
        );
        const page = pages[0];
        if (!page) throw pageError('页面不存在', 'page_not_found');
        if (asNumber(input.expectedVersion) !== asNumber(page.version_no)) {
          throw pageError('页面已被更新，请刷新后重试', 'version_conflict', {
            currentVersion: asNumber(page.version_no),
          });
        }
        nextVersionNo = asNumber(page.version_no) + 1;
      } else {
        pageId = idFactory();
      }

      const contentAssetId = idFactory();
      const assetVersionId = idFactory();
      const pageVersionId = idFactory();
      const checksum = crypto.createHash('sha256').update(normalized.content).digest('hex');
      const stored = await writeVersionContent(
        userId,
        contentAssetId,
        assetVersionId,
        normalized.content,
      );
      writtenPath = stored.target;
      const now = Date.now();

      const contentAssetType = normalized.contentFormat === 'html' ? 'html' : 'markdown';
      const contentMimeType =
        normalized.contentFormat === 'html' ? 'text/html' : 'text/markdown';
      const contentExtension = normalized.contentFormat === 'html' ? 'html' : 'md';
      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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'none',
                 'private', 'ready', ?, ?, ?)`,
        [
          contentAssetId,
          userId,
          space.id,
          category.id,
          contentAssetType,
          contentMimeType,
          `${pageId}-v${nextVersionNo}.${contentExtension}`,
          `${normalized.title} · v${nextVersionNo}`,
          assetVersionId,
          normalized.contentBytes,
          checksum,
          source.type ?? 'generated',
          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, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
        [
          assetVersionId,
          contentAssetId,
          stored.storageKey,
          normalized.contentBytes,
          checksum,
          contentMimeType,
          userId,
          input.changeNote ?? '保存页面草稿',
          now,
        ],
      );

      if (nextVersionNo === 1) {
        await conn.query(
          `INSERT INTO h5_page_records
           (id, user_id, space_id, category_id, source_session_id, source_message_id,
            source_asset_id, title, summary, page_type, template_id, draft_content_ref,
            current_version_id, status, visibility, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`,
          [
            pageId,
            userId,
            space.id,
            category.id,
            source.sessionId ?? null,
            source.messageId ?? null,
            source.assetId ?? null,
            normalized.title,
            normalized.summary,
            normalized.pageType,
            normalized.templateId,
            stored.storageKey,
            pageVersionId,
            now,
            now,
          ],
        );
      }
      await conn.query(
        `INSERT INTO h5_page_versions
         (id, page_id, version_no, content_asset_id, source_snapshot_json,
          created_by, change_note, immutable, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`,
        [
          pageVersionId,
          pageId,
          nextVersionNo,
          contentAssetId,
          JSON.stringify(source.snapshot ?? {}),
          userId,
          input.changeNote ?? '保存页面草稿',
          now,
        ],
      );
      if (nextVersionNo > 1) {
        await conn.query(
          `UPDATE h5_page_records
           SET title = ?, summary = ?, page_type = ?, template_id = ?,
               draft_content_ref = ?, current_version_id = ?, updated_at = ?
           WHERE id = ? AND user_id = ?`,
          [
            normalized.title,
            normalized.summary,
            normalized.pageType,
            normalized.templateId,
            stored.storageKey,
            pageVersionId,
            now,
            pageId,
            userId,
          ],
        );
      }
      await conn.query(
        `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [normalized.contentBytes, now, space.id, userId],
      );
      await conn.commit();
      if (normalized.contentFormat === 'html') {
        const workspaceHtmlRelativePath = source.snapshot?.relative_path ?? null;
        const workspacePublishDir = workspaceHtmlRelativePath
          ? await resolveWorkspacePublishDir(userId)
          : null;
        if (workspacePublishDir && workspaceHtmlRelativePath) {
          await ensureWorkspaceHtmlThumbnail(
            workspacePublishDir,
            workspaceHtmlRelativePath,
            normalized.content,
            {
              title: normalized.title,
              subtitle: normalized.summary || `草稿 v${nextVersionNo}`,
              contentStorageKey: stored.storageKey,
            },
          ).catch(() => {});
        }
        const thumbMeta = {
          title: normalized.title,
          subtitle: normalized.summary || `草稿 v${nextVersionNo}`,
          contentStorageKey: stored.storageKey,
        };
        queueMicrotask(() => {
          void ensurePageThumbnail({
            storageRoot,
            pageThumbnailStorageKey: pageThumbnailKey(userId, pageId),
            html: normalized.content,
            meta: thumbMeta,
            workspacePublishDir,
            workspaceHtmlRelativePath,
          }).catch(() => {});
        });
      }
      return getPage(userId, pageId);
    } catch (error) {
      await conn.rollback();
      if (writtenPath) await fs.rm(writtenPath, { force: true }).catch(() => {});
      throw error;
    } finally {
      conn.release();
    }
  };

  const findPageBySourceAsset = async (userId, assetId) => {
    if (!assetId) return null;
  const [rows] = await pool.query(
      `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
       FROM h5_page_records p
       JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
       LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
       LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
       WHERE p.user_id = ? AND p.source_asset_id = ? AND p.status <> 'deleted'
       ORDER BY p.updated_at DESC
       LIMIT 1`,
      [userId, assetId],
    );
    return rows[0] ? pageResponse(rows[0]) : null;
  };

  const listPages = async (userId, filters = {}) => {
    const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
    const params = [userId];
    if (filters.status) {
      clauses.push(`p.status = ?`);
      params.push(filters.status);
    }
    const [rows] = await pool.query(
      `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
       FROM h5_page_records p
       JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
       LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
       LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
       WHERE ${clauses.join(' AND ')}
       ORDER BY p.updated_at DESC LIMIT 100`,
      params,
    );
    return rows.map(pageResponse);
  };

  async function getPage(userId, pageId) {
    const [rows] = await pool.query(
      `SELECT p.*, c.category_code, pv.version_no, av.storage_key
       FROM h5_page_records p
       JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
       JOIN h5_page_versions pv ON pv.id = p.current_version_id
       JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
       WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
       LIMIT 1`,
      [pageId, userId],
    );
    const row = rows[0];
    if (!row) throw pageError('页面不存在', 'page_not_found');
    const storagePath = await resolveReadableStoragePath(row.storage_key);
    const content = await fs.readFile(storagePath, 'utf8');
    return pageResponse({ ...row, content });
  }

  const listVersions = async (userId, pageId) => {
    await getPage(userId, pageId);
    const [rows] = await pool.query(
      `SELECT pv.id, pv.version_no, pv.change_note, pv.immutable, pv.created_at
       FROM h5_page_versions pv
       JOIN h5_page_records p ON p.id = pv.page_id
       WHERE pv.page_id = ? AND p.user_id = ?
       ORDER BY pv.version_no DESC`,
      [pageId, userId],
    );
    return rows.map((row) => ({
      id: row.id,
      versionNo: asNumber(row.version_no),
      changeNote: row.change_note,
      immutable: Boolean(row.immutable),
      createdAt: asNumber(row.created_at),
    }));
  };

  const redactPage = async (userId, pageId, input = {}) => {
    const page = await getPage(userId, pageId);
    if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) {
      throw pageError('只能基于当前版本修复页面', 'version_conflict', {
        currentVersion: page.currentVersionId,
      });
    }
    if (input.expectedVersion != null && asNumber(input.expectedVersion) !== asNumber(page.versionNo)) {
      throw pageError('页面已被更新，请刷新后重试', 'version_conflict', {
        currentVersion: asNumber(page.versionNo),
      });
    }

    const contentFormat = page.contentFormat === 'html' ? 'html' : 'markdown';
    const scanFormat = contentFormat === 'html' ? 'html' : 'text';
    const sourceTitle = input.title ?? page.title;
    const sourceSummary = input.summary ?? page.summary ?? '';
    const sourceContent = input.content ?? page.content ?? '';
    const titleRedaction = redactContent(sourceTitle, { format: 'text' });
    const summaryRedaction = redactContent(sourceSummary, { format: 'text' });
    const contentRedaction = redactContent(sourceContent, { format: scanFormat });
    const fieldChanges = (field, fieldLabel, redaction) =>
      redaction.findings.map((finding) => ({
        field,
        fieldLabel,
        type: finding.type,
        label: finding.label,
        count: finding.occurrenceCount,
      }));
    const changes = [
      ...fieldChanges('title', '标题', titleRedaction),
      ...fieldChanges('summary', '摘要', summaryRedaction),
      ...fieldChanges('content', '正文', contentRedaction),
    ];
    const redactionsApplied = changes.reduce((total, change) => total + change.count, 0);

    if (redactionsApplied === 0) {
      throw pageError('未发现需要修复的风险内容', 'redaction_not_needed');
    }

    const updated = await createVersion(
      userId,
      {
        pageId,
        expectedVersion: page.versionNo,
        title: titleRedaction.content,
        summary: summaryRedaction.content.trim() || sourceSummary,
        content: contentRedaction.content,
        templateId: page.templateId,
        pageType: page.pageType,
        contentFormat,
        changeNote: `一键修复 v${page.versionNo + 1}`,
      },
      {
        type: 'generated',
        snapshot: {
          desensitized: true,
        },
      },
    );

    return {
      page: updated,
      originalScan: scanContent(`${sourceTitle}\n${sourceSummary}\n${sourceContent}`, {
        format: scanFormat,
      }),
      redactedScan: scanContent(
        `${updated.title}\n${updated.summary ?? ''}\n${updated.content ?? ''}`,
        { format: scanFormat },
      ),
      redactionsApplied,
      changes,
    };
  };

  const localizePrivateResources = async (userId, pageId, input = {}) => {
    const page = await getPage(userId, pageId);
    if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) {
      throw pageError('只能基于当前版本修复页面', 'version_conflict', {
        currentVersion: page.currentVersionId,
      });
    }
    if (input.expectedVersion != null && asNumber(input.expectedVersion) !== asNumber(page.versionNo)) {
      throw pageError('页面已被更新，请刷新后重试', 'version_conflict', {
        currentVersion: asNumber(page.versionNo),
      });
    }
    if (page.contentFormat !== 'html') {
      throw pageError('该页面不支持资源本地化修复', 'unsupported_publish_fix');
    }

    const sourceTitle = input.title ?? page.title;
    const sourceSummary = input.summary ?? page.summary ?? '';
    const sourceContent = input.content ?? page.content ?? '';
    const matches = [...String(sourceContent).matchAll(PRIVATE_ASSET_URL_PATTERN)];
    if (matches.length === 0) {
      throw pageError('未发现可安全修复的私有图片引用', 'publish_fix_not_needed');
    }

    const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))];
    const [assets] = await pool.query(
      `SELECT a.id, a.mime_type, a.original_filename, v.storage_key
       FROM h5_assets a
       JOIN h5_asset_versions v ON v.id = a.current_version_id
       WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%'
         AND a.status <> 'deleted'`,
      [userId, assetIds],
    );
    const byId = new Map(assets.map((asset) => [asset.id, asset]));
    const replacements = new Map();
    for (const assetId of assetIds) {
      const asset = byId.get(assetId);
      if (!asset) continue;
      const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
      const mimeType = String(asset.mime_type || 'application/octet-stream');
      const ext = extensionForMime(mimeType, asset.original_filename);
      const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`;
      replacements.set(assetId, { dataUri, ext });
    }

    if (replacements.size === 0) {
      throw pageError('私有资源不是可内联的图片，无法自动修复', 'unsupported_publish_fix');
    }

    let changedCount = 0;
    const updatedContent = String(sourceContent).replace(
      PRIVATE_ASSET_URL_PATTERN,
      (value, assetId) => {
        const replacement = replacements.get(assetId);
        if (!replacement) return value;
        changedCount += 1;
        return replacement.dataUri;
      },
    );

    if (updatedContent === sourceContent || changedCount === 0) {
      throw pageError('未发现可安全修复的私有图片引用', 'publish_fix_not_needed');
    }

    const updated = await createVersion(
      userId,
      {
        pageId,
        expectedVersion: page.versionNo,
        title: sourceTitle,
        summary: sourceSummary,
        content: updatedContent,
        templateId: page.templateId,
        pageType: page.pageType,
        contentFormat: 'html',
        changeNote: `一键发布修复 v${page.versionNo + 1}`,
      },
      {
        type: 'generated',
        snapshot: {
          localized_private_resources: true,
        },
      },
    );

    return {
      page: updated,
      originalScan: scanContent(`${sourceTitle}\n${sourceSummary}\n${sourceContent}`, {
        format: 'html',
      }),
      redactedScan: scanContent(
        `${updated.title}\n${updated.summary ?? ''}\n${updated.content ?? ''}`,
        { format: 'html' },
      ),
      redactionsApplied: changedCount,
      changes: [
        {
          field: 'content',
          fieldLabel: '正文',
          type: 'private_resource_reference',
          label: '私有资源引用',
          count: changedCount,
        },
      ],
    };
  };

  const collectLinkedAssets = async (conn, userId, pageId) => {
    const [versions] = await conn.query(
      `SELECT pv.content_asset_id, pv.bundle_asset_id
       FROM h5_page_versions pv
       WHERE pv.page_id = ?`,
      [pageId],
    );
    const assetKind = new Map();
    for (const version of versions) {
      if (version.content_asset_id) assetKind.set(version.content_asset_id, 'content');
      if (version.bundle_asset_id) assetKind.set(version.bundle_asset_id, 'bundle');
    }
    const assetIds = [...assetKind.keys()];
    if (assetIds.length === 0) {
      return { assets: [], totalBytes: 0, assetIds: [] };
    }
    const [assets] = await conn.query(
      `SELECT id, display_name, size_bytes, status
       FROM h5_assets
       WHERE user_id = ? AND id IN (?) AND status <> 'deleted'`,
      [userId, assetIds],
    );
    const linkedAssets = assets.map((asset) => ({
      id: asset.id,
      displayName: asset.display_name,
      sizeBytes: asNumber(asset.size_bytes),
      kind: assetKind.get(asset.id) ?? 'content',
    }));
    return {
      assets: linkedAssets,
      totalBytes: linkedAssets.reduce((sum, asset) => sum + asset.sizeBytes, 0),
      assetIds: linkedAssets.map((asset) => asset.id),
    };
  };

  const offlineOnlinePublications = async (conn, userId, pageId, now) => {
    const [rows] = await conn.query(
      `SELECT id, page_version_id, access_mode
       FROM h5_publish_records
       WHERE page_id = ? AND user_id = ? AND status = 'online'
       FOR UPDATE`,
      [pageId, userId],
    );
    for (const publication of rows) {
      await conn.query(
        `UPDATE h5_publish_records
         SET status = 'offline', offline_at = ?, updated_at = ?
         WHERE id = ?`,
        [now, now, publication.id],
      );
      await conn.query(
        `INSERT INTO h5_publication_events
         (id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id,
          access_mode, detail_json, created_at)
         VALUES (?, ?, 'offlined', ?, ?, NULL, ?, ?, ?)`,
        [
          idFactory(),
          publication.id,
          userId,
          publication.page_version_id,
          publication.access_mode,
          JSON.stringify({ reason: 'page_deleted' }),
          now,
        ],
      );
    }
    return rows.length;
  };

  const softDeleteLinkedAssets = async (conn, userId, spaceId, assetIds, now) => {
    let freedBytes = 0;
    let deletedAssetCount = 0;
    for (const assetId of assetIds) {
      const [rows] = await conn.query(
        `SELECT id, size_bytes
         FROM h5_assets
         WHERE id = ? AND user_id = ? AND status <> 'deleted'
         LIMIT 1 FOR UPDATE`,
        [assetId, userId],
      );
      const asset = rows[0];
      if (!asset) continue;
      await conn.query(
        `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [now, now, assetId, userId],
      );
      freedBytes += asNumber(asset.size_bytes);
      deletedAssetCount += 1;
    }
    if (freedBytes > 0) {
      await conn.query(
        `UPDATE h5_user_spaces
         SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [freedBytes, now, spaceId, userId],
      );
    }
    return { deletedAssetCount, freedBytes };
  };

  const getDeletePreview = async (userId, pageId) => {
    const [rows] = await pool.query(
      `SELECT p.id, p.title, p.source_asset_id, p.status
       FROM h5_page_records p
       WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
       LIMIT 1`,
      [pageId, userId],
    );
    const page = rows[0];
    if (!page) throw pageError('页面不存在', 'page_not_found');

    const conn = await pool.getConnection();
    try {
      const [versionRows] = await conn.query(
        `SELECT COUNT(*) AS version_count FROM h5_page_versions WHERE page_id = ?`,
        [pageId],
      );
      const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId);
      const [publications] = await conn.query(
        `SELECT id, status, public_url, access_mode, view_count
         FROM h5_publish_records
         WHERE page_id = ? AND user_id = ?
         ORDER BY published_at DESC`,
        [pageId, userId],
      );
      const [agentJobs] = await conn.query(
        `SELECT COUNT(*) AS job_count
         FROM h5_agent_jobs
         WHERE user_id = ? AND result_page_id = ?`,
        [userId, pageId],
      );
      const onlinePublication = publications.find((item) => item.status === 'online') ?? null;
      const offlinePublicationCount = publications.filter((item) => item.status !== 'online').length;
      const preview = {
        page: {
          id: page.id,
          title: page.title,
          status: page.status,
          versionCount: asNumber(versionRows[0]?.version_count),
        },
        onlinePublication: onlinePublication
          ? {
              id: onlinePublication.id,
              publicUrl: onlinePublication.public_url,
              accessMode: onlinePublication.access_mode,
              viewCount: asNumber(onlinePublication.view_count),
            }
          : null,
        offlinePublicationCount,
        linkedAssets: assets,
        linkedAssetBytes: totalBytes,
        linkedAgentJobCount: asNumber(agentJobs[0]?.job_count),
        preservesSourceAsset: Boolean(page.source_asset_id),
      };
      return { ...preview, summaryLines: buildPageDeleteSummary(preview) };
    } finally {
      conn.release();
    }
  };

  const deletePage = async (userId, pageId) => {
    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();
      const [pages] = await conn.query(
        `SELECT id, space_id, title
         FROM h5_page_records
         WHERE id = ? AND user_id = ? AND status <> 'deleted'
         LIMIT 1 FOR UPDATE`,
        [pageId, userId],
      );
      const page = pages[0];
      if (!page) throw pageError('页面不存在', 'page_not_found');

      const now = Date.now();
      const { assetIds } = await collectLinkedAssets(conn, userId, pageId);
      const offlinedPublicationCount = await offlineOnlinePublications(conn, userId, pageId, now);
      const { deletedAssetCount, freedBytes } = await softDeleteLinkedAssets(
        conn,
        userId,
        page.space_id,
        assetIds,
        now,
      );
      const [agentJobRows] = await conn.query(
        `SELECT COUNT(*) AS job_count
         FROM h5_agent_jobs
         WHERE user_id = ? AND result_page_id = ?`,
        [userId, pageId],
      );
      const clearedAgentJobCount = asNumber(agentJobRows[0]?.job_count);

      await conn.query(
        `UPDATE h5_agent_jobs
         SET result_page_id = NULL, updated_at = ?
         WHERE user_id = ? AND result_page_id = ?`,
        [now, userId, pageId],
      );
      await conn.query(
        `UPDATE h5_page_records
         SET status = 'deleted', deleted_at = ?, updated_at = ?,
             current_publish_id = NULL, visibility = 'private'
         WHERE id = ? AND user_id = ?`,
        [now, now, pageId, userId],
      );
      await conn.commit();
      return {
        deleted: true,
        pageId,
        title: page.title,
        offlinedPublicationCount,
        deletedAssetCount,
        freedBytes,
        clearedAgentJobCount,
      };
    } catch (error) {
      await conn.rollback();
      throw error;
    } finally {
      conn.release();
    }
  };

  const loadPageThumbnailContext = async (userId, pageId) => {
    const [rows] = await pool.query(
      `SELECT p.title, p.summary, p.page_type, pv.version_no, av.storage_key, pv.source_snapshot_json
       FROM h5_page_records p
       JOIN h5_page_versions pv ON pv.id = p.current_version_id
       JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
       WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
       LIMIT 1`,
      [pageId, userId],
    );
    const row = rows[0];
    if (!row) throw pageError('页面不存在', 'page_not_found');
    if (row.page_type !== 'html') {
      throw pageError('该页面不支持缩略图', 'thumbnail_not_supported');
    }
    const storagePath = await resolveReadableStoragePath(row.storage_key);
    const content = await fs.readFile(storagePath, 'utf8');
    let snapshot = {};
    try {
      snapshot = JSON.parse(row.source_snapshot_json ?? '{}');
    } catch {
      snapshot = {};
    }
    const workspaceHtmlRelativePath = snapshot.relative_path ?? null;
    const workspacePublishDir = workspaceHtmlRelativePath
      ? await resolveWorkspacePublishDir(userId)
      : null;
    return {
      row,
      content,
      workspaceHtmlRelativePath,
      workspacePublishDir,
      thumbnailStorageKey: pageThumbnailKey(userId, pageId),
    };
  };

  const syncWorkspaceThumbnail = async ({
    workspacePublishDir,
    workspaceHtmlRelativePath,
    html,
    meta,
  }) => {
    if (!workspacePublishDir || !workspaceHtmlRelativePath) return;
    await ensureWorkspaceHtmlThumbnail(
      workspacePublishDir,
      workspaceHtmlRelativePath,
      html,
      { ...meta, force: true },
    ).catch(() => {});
  };

  const uploadThumbnail = async (userId, pageId, input = {}) => {
    const imageBase64 = String(input.imageBase64 ?? '').trim();
    if (!imageBase64) throw pageError('请上传图片', 'thumbnail_image_required');
    const buffer = Buffer.from(imageBase64, 'base64');
    const coverDataUri = bufferToImageDataUri(buffer);
    if (!coverDataUri) throw pageError('图片无效或过大', 'thumbnail_image_invalid');

    const ctx = await loadPageThumbnailContext(userId, pageId);
    const html = String(input.html ?? ctx.content);
    const title = String(input.title ?? ctx.row.title ?? '').trim() || ctx.row.title;
    const summary = String(input.summary ?? ctx.row.summary ?? '').trim();
    const signals = extractCoverSignals(html, {
      title,
      subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`,
    });
    const svg = buildFeedThumbnailSvg(signals, { coverDataUri });
    await writeThumbnail(storageRoot, ctx.thumbnailStorageKey, svg);
    if (ctx.workspacePublishDir && ctx.workspaceHtmlRelativePath) {
      await writeThumbnail(
        ctx.workspacePublishDir,
        workspaceThumbnailRelativePath(ctx.workspaceHtmlRelativePath),
        svg,
      );
    }
    return { updatedAt: Date.now() };
  };

  const regenerateThumbnail = async (userId, pageId, input = {}, options = {}) => {
    const ctx = await loadPageThumbnailContext(userId, pageId);
    let html = String(input.html ?? ctx.content);
    const title = String(input.title ?? ctx.row.title ?? '').trim() || ctx.row.title;
    const summary = String(input.summary ?? ctx.row.summary ?? '').trim();
    let updatedContent = null;
    let metaOverrides = {};

    if (input.useAi) {
      if (!options.suggestCoverMeta) {
        throw pageError('AI 封面生成未启用', 'cover_ai_unavailable');
      }
      metaOverrides = await options.suggestCoverMeta({
        title,
        summary,
        html,
        instruction: input.instruction,
      });
      updatedContent = upsertMindspaceCoverMeta(html, metaOverrides);
      html = updatedContent;
    }

    const meta = {
      title,
      subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`,
      contentStorageKey: ctx.row.storage_key,
      force: true,
      ...metaOverrides,
    };

    await generateHtmlThumbnail(storageRoot, ctx.thumbnailStorageKey, html, meta);
    await syncWorkspaceThumbnail({
      workspacePublishDir: ctx.workspacePublishDir,
      workspaceHtmlRelativePath: ctx.workspaceHtmlRelativePath,
      html,
      meta,
    });

    return {
      updatedAt: Date.now(),
      content: updatedContent,
    };
  };

  return {
    createFromChat: (userId, input, source) =>
      createVersion(userId, input, {
        type: 'chat',
        sessionId: source.sessionId,
        messageId: source.messageId,
        assetId: source.assetId ?? null,
        snapshot: source.snapshot,
      }),
    createFromAgent: (userId, input, source) =>
      createVersion(userId, input, {
        type: 'agent',
        assetId: source.assetId ?? null,
        snapshot: {
          job_id: source.jobId ?? null,
          source_asset_ids: source.assetIds ?? [],
        },
      }),
    createPage: (userId, input) => createVersion(userId, input, { type: 'template' }),
    updatePage: (userId, pageId, input) =>
      createVersion(userId, { ...input, pageId }, { type: 'generated' }),
    localizePrivateResources,
    redactPage,
    createRedactedCopy: redactPage,
    listPages,
    findPageBySourceAsset,
    getPage,
    getDeletePreview,
    deletePage,
    listVersions,
    renderPreview: async (userId, pageId) => {
      const page = await getPage(userId, pageId);
      const html =
        page.contentFormat === 'html'
          ? renderHtmlPreview(page.content)
          : renderPreviewHtml(page);
      return { html, contentFormat: page.contentFormat };
    },
    renderDraftPreview: async (userId, pageId, input = {}) => {
      const page = await getPage(userId, pageId);
      const title = String(input.title ?? page.title ?? '').trim() || page.title;
      const summary = String(input.summary ?? page.summary ?? '').trim();
      const content = String(input.content ?? page.content ?? '');
      const templateId = input.templateId ?? page.templateId;
      const html =
        page.contentFormat === 'html'
          ? renderHtmlPreview(content)
          : renderPreviewHtml({
              title,
              summary,
              templateId,
              versionNo: page.versionNo,
              content,
            });
      return { html, contentFormat: page.contentFormat };
    },
    renderThumbnail: async (userId, pageId) => {
      const ctx = await loadPageThumbnailContext(userId, pageId);
      return ensurePageThumbnail({
        storageRoot,
        pageThumbnailStorageKey: ctx.thumbnailStorageKey,
        html: ctx.content,
        meta: {
          title: ctx.row.title,
          subtitle: ctx.row.summary || `草稿 v${asNumber(ctx.row.version_no)}`,
          contentStorageKey: ctx.row.storage_key,
        },
        workspacePublishDir: ctx.workspacePublishDir,
        workspaceHtmlRelativePath: ctx.workspaceHtmlRelativePath,
      });
    },
    uploadThumbnail,
    regenerateThumbnail,
  };
}

export const pageInternals = {
  escapeHtml,
  normalizePageInput,
  renderContent,
  renderPreviewHtml,
  renderHtmlPreview,
  previewContentSecurityPolicy,
  isHtmlPage,
  renderPublicationHtml,
  redactContent,
  buildPageDeleteSummary,
};

export function buildPageDeleteSummary(preview) {
  const lines = [
    `页面「${preview.page.title}」及其 ${preview.page.versionCount} 个版本`,
  ];
  if (preview.onlinePublication) {
    lines.push(`在线公开链接将立即下线：${preview.onlinePublication.publicUrl}`);
  }
  if (preview.offlinePublicationCount > 0) {
    lines.push(`${preview.offlinePublicationCount} 条历史发布记录将保留审计信息，但不再可访问`);
  }
  if (preview.linkedAssets.length > 0) {
    lines.push(
      `${preview.linkedAssets.length} 个页面内容资产将被删除（约 ${formatByteSize(preview.linkedAssetBytes)}）`,
    );
  }
  if (preview.linkedAgentJobCount > 0) {
    lines.push(`${preview.linkedAgentJobCount} 个 Agent 任务结果关联将被清除`);
  }
  if (preview.preservesSourceAsset) {
    lines.push('原始上传资料不会被删除');
  }
  return lines;
}

function formatByteSize(bytes) {
  const value = asNumber(bytes);
  if (value < 1024) return `${value} B`;
  if (value < 1024 * 1024) return `${(value / 1024).toFixed(value % 1024 === 0 ? 0 : 1)} KB`;
  return `${(value / (1024 * 1024)).toFixed(value % (1024 * 1024) === 0 ? 0 : 1)} MB`;
}
