import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs';
import { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs';
import { pageInternals } from './mindspace-pages.mjs';
import { loadMindSpaceConfig } from './mindspace-config.mjs';
import { resolvePublicBaseUrl } from './user-publish.mjs';

const SCANNER_VERSION = 'mindspace-content-v1';
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
  /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
const ACCESS_MODES = new Set([
  'public',
  'password',
  'private_link',
  'time_limited',
  'login_required',
  'owner_only',
]);
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;

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

function normalizeSlug(value) {
  const slug = String(value ?? '').normalize('NFKC').trim().toLowerCase();
  if (!SLUG_PATTERN.test(slug)) {
    throw publicationError('页面地址只能包含小写字母、数字和连字符', 'invalid_publish_input');
  }
  return slug;
}

async function findAvailableSlug(pool, userId, preferredSlug, excludePageId) {
  const normalized = normalizeSlug(preferredSlug);
  const candidates = [normalized];
  for (let index = 2; index <= 20; index += 1) {
    candidates.push(`${normalized}-${index}`);
  }
  candidates.push(`${normalized}-${excludePageId.replace(/-/g, '').slice(0, 8)}`);
  for (const candidate of candidates) {
    if (!SLUG_PATTERN.test(candidate)) continue;
    const [rows] = await pool.query(
      `SELECT id FROM h5_publish_records
       WHERE user_id = ? AND url_slug = ? AND status = 'online' AND page_id <> ?
       LIMIT 1`,
      [userId, candidate, excludePageId],
    );
    if (!rows[0]) return candidate;
  }
  return `${normalized}-${crypto.randomBytes(3).toString('hex')}`;
}

function normalizeAccessMode(value) {
  const mode = String(value ?? 'public');
  if (!ACCESS_MODES.has(mode)) {
    throw publicationError('不支持的访问方式', 'invalid_publish_input');
  }
  return mode;
}

function normalizePassword(value, required) {
  const password = String(value ?? '');
  if (!required && !password) return null;
  if (password.length < 8 || password.length > 128) {
    throw publicationError('访问密码长度必须为 8 到 128 个字符', 'invalid_publish_input');
  }
  return password;
}

function normalizeExpiresAt(value, required) {
  if (!required && (value == null || value === '')) return null;
  const expiresAt = Number(value);
  if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) {
    throw publicationError('过期时间必须晚于当前时间', 'invalid_publish_input');
  }
  return expiresAt;
}

function hashPassword(password) {
  const salt = crypto.randomBytes(16);
  const hash = crypto.scryptSync(password, salt, 32);
  return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
}

function verifyPassword(password, encoded) {
  const [algorithm, saltHex, hashHex] = String(encoded ?? '').split('$');
  if (algorithm !== 'scrypt' || !saltHex || !hashHex) return false;
  const expected = Buffer.from(hashHex, 'hex');
  const actual = crypto.scryptSync(String(password ?? ''), Buffer.from(saltHex, 'hex'), expected.length);
  return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}

function deviceType(userAgent) {
  const value = String(userAgent ?? '');
  if (/bot|crawler|spider|slurp/i.test(value)) return 'bot';
  if (/ipad|tablet|kindle|silk/i.test(value)) return 'tablet';
  if (/mobile|iphone|android/i.test(value)) return 'mobile';
  if (value) return 'desktop';
  return 'unknown';
}

function referrerHost(referrer) {
  if (!referrer) return null;
  try {
    return new URL(referrer).hostname.slice(0, 255) || null;
  } catch {
    return null;
  }
}

function publicationResponse(row) {
  if (!row) return null;
  return {
    id: row.id,
    pageId: row.page_id,
    pageVersionId: row.page_version_id,
    urlSlug: row.url_slug,
    publicUrl: row.public_url,
    accessMode: row.access_mode,
    expiresAt: row.expires_at == null ? null : Number(row.expires_at),
    status: row.status,
    viewCount: Number(row.view_count ?? 0),
    publishedAt: Number(row.published_at),
    offlineAt: row.offline_at == null ? null : Number(row.offline_at),
  };
}

function publicHomepageResponse(owner, pages) {
  const totalViews = pages.reduce(
    (sum, page) => sum + Number(page.viewCount ?? page.view_count ?? 0),
    0,
  );
  return {
    owner: {
      id: owner.id,
      slug: owner.slug,
      username: owner.username,
      displayName: owner.display_name || owner.username,
    },
    totalViews,
    pageCount: pages.length,
    pages: pages.map((page) => ({
      id: page.id,
      pageId: page.page_id,
      title: page.title,
      summary: page.summary ?? '',
      templateId: page.template_id,
      publicUrl: page.public_url,
      urlSlug: page.url_slug,
      viewCount: Number(page.view_count ?? 0),
      publishedAt: Number(page.published_at),
    })),
  };
}

function buildPublicationThumbnailFallback(ownerSlug, urlSlug) {
  return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`;
}

async function localizePrivateImageReferences({ pool, userId, html, absoluteStoragePath }) {
  const source = String(html ?? '');
  const matches = [...source.matchAll(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN)];
  if (matches.length === 0) return source;

  const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))];
  if (assetIds.length === 0) return source;

  const [assets] = await pool.query(
    `SELECT a.id, a.mime_type, 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 mimeType = String(asset.mime_type || 'application/octet-stream');
    const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
    replacements.set(assetId, `data:${mimeType};base64,${buffer.toString('base64')}`);
  }

  if (replacements.size === 0) return source;
  return source.replace(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN, (value, assetId) => {
    return replacements.get(assetId) ?? value;
  });
}

async function prepareHtmlPublishContent({
  pool,
  userId,
  html,
  ownerSlug,
  urlSlug,
  absoluteStoragePath,
}) {
  let publishContent = (await localizeGoogleFontsCss(html)).html;
  publishContent = await localizePrivateImageReferences({
    pool,
    userId,
    html: publishContent,
    absoluteStoragePath,
  });
  return replacePrivateResourceReferences(
    publishContent,
    buildPublicationThumbnailFallback(ownerSlug, urlSlug),
  );
}

export function createPublicationService(pool, options = {}) {
  const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
  const idFactory = options.idFactory ?? (() => crypto.randomUUID());
  const publicPageLimitFallback = Number(options.publicPageLimit ?? 5);
  const resolvePublicPageLimit = async () => {
    try {
      const config = await loadMindSpaceConfig(pool);
      return Number(config.publicPageLimit ?? publicPageLimitFallback);
    } catch {
      return publicPageLimitFallback;
    }
  };

  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 loadVersion = async (userId, pageId, pageVersionId) => {
    const [rows] = await pool.query(
      `SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id,
              p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no,
              pv.bundle_asset_id, av.storage_key
       FROM h5_page_records p
       JOIN h5_page_versions pv ON pv.page_id = p.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 pv.id = ? AND p.status <> 'deleted'
       LIMIT 1`,
      [pageId, userId, pageVersionId],
    );
    const row = rows[0];
    if (!row) throw publicationError('页面版本不存在', 'page_not_found');
    return {
      ...row,
      version_no: Number(row.version_no),
      content: await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'),
    };
  };

  const loadOwnerSlug = async (userId) => {
    const [rows] = await pool.query(
      `SELECT COALESCE(slug, username) AS public_slug
       FROM h5_users
       WHERE id = ?
       LIMIT 1`,
      [userId],
    );
    const ownerSlug = String(rows[0]?.public_slug ?? '').trim();
    if (!ownerSlug) throw publicationError('用户公开地址不存在', 'publication_owner_not_found');
    return ownerSlug;
  };

  const preparePublishContent = async (page, ownerSlug, urlSlug) => {
    let publishContent = page.content;
    if (page.page_type !== 'html') return publishContent;
    return prepareHtmlPublishContent({
      pool,
      userId: page.user_id,
      html: publishContent,
      ownerSlug,
      urlSlug,
      absoluteStoragePath,
    });
  };

  const persistScan = async (conn, userId, pageVersionId, scan, now) => {
    const scanId = idFactory();
    await conn.query(
      `INSERT INTO h5_security_scans
       (id, user_id, target_type, target_id, scanner_version, status, risk_level,
        findings_count, summary_json, started_at, completed_at)
       VALUES (?, ?, 'page_version', ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        scanId,
        userId,
        pageVersionId,
        SCANNER_VERSION,
        scan.status,
        scan.riskLevel,
        scan.findings.length,
        JSON.stringify({ types: scan.findings.map((finding) => finding.type) }),
        now,
        now,
      ],
    );
    for (const finding of scan.findings) {
      await conn.query(
        `INSERT INTO h5_security_findings
         (id, scan_id, finding_type, risk_level, occurrence_count, sample_masked, blocking, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
        [
          idFactory(),
          scanId,
          finding.type,
          finding.riskLevel,
          finding.occurrenceCount,
          finding.sampleMasked,
          finding.blocking ? 1 : 0,
          now,
        ],
      );
    }
    return scanId;
  };

  const check = async (userId, pageId, input) => {
    const slug = normalizeSlug(input.urlSlug);
    const accessMode = normalizeAccessMode(input.accessMode);
    normalizePassword(input.password, accessMode === 'password');
    const expiresAt = normalizeExpiresAt(
      input.expiresAt,
      accessMode === 'time_limited',
    );
    const page = await loadVersion(userId, pageId, input.pageVersionId);
    if (page.page_version_id !== page.current_version_id) {
      throw publicationError('只能发布页面的当前版本', 'invalid_state_transition');
    }
    const [conflicts] = await pool.query(
      `SELECT pr.id, pr.page_id, p.title
       FROM h5_publish_records pr
       JOIN h5_page_records p ON p.id = pr.page_id
       WHERE pr.user_id = ? AND pr.url_slug = ? AND pr.status = 'online'
         AND pr.page_id <> ? LIMIT 1`,
      [userId, slug, pageId],
    );
    const conflict = conflicts[0] ?? null;
    const ownerSlug = await loadOwnerSlug(userId);
    const publishContent = await preparePublishContent(page, ownerSlug, slug);
    const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, {
      format: page.page_type === 'html' ? 'html' : 'text',
      allowHtmlActiveContent: page.page_type === 'html',
    });
    const suggestedUrlSlug =
      conflict && accessMode !== 'private_link'
        ? await findAvailableSlug(pool, userId, slug, pageId)
        : null;
    return {
      pageVersionId: page.page_version_id,
      urlSlug: slug,
      accessMode,
      expiresAt,
      slugAvailable: accessMode === 'private_link' || !conflict,
      slugConflict: conflict
        ? { pageId: conflict.page_id, title: conflict.title }
        : null,
      suggestedUrlSlug,
      ...scan,
      allowed: (accessMode === 'private_link' || !conflict) && scan.allowed,
    };
  };

  const publish = async (userId, pageId, input) => {
    const result = await check(userId, pageId, input);
    if (!result.slugAvailable) throw publicationError('页面地址已被使用', 'slug_conflict');
    if (!result.allowed) {
      throw publicationError('页面包含不可公开的高风险信息', 'security_risk_blocked', {
        findings: result.findings,
      });
    }
    const acknowledged = new Set(input.acknowledgedFindingIds ?? []);
    const missingAcknowledgements = result.findings.filter(
      (finding) => !finding.blocking && !acknowledged.has(finding.id),
    );
    if (missingAcknowledgements.length) {
      throw publicationError('请先确认发布检查中的风险提示', 'security_ack_required', {
        findings: missingAcknowledgements,
      });
    }
    const page = await loadVersion(userId, pageId, result.pageVersionId);
    const ownerSlug = await loadOwnerSlug(userId);
    const publishContent = await preparePublishContent(page, ownerSlug, result.urlSlug);
    const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent });
    const htmlBytes = Buffer.byteLength(html);
    const conn = await pool.getConnection();
    let writtenPath;
    try {
      await conn.beginTransaction();
      const [spaces] = await conn.query(
        `SELECT quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces
         WHERE id = ? AND user_id = ? FOR UPDATE`,
        [page.space_id, userId],
      );
      const available =
        Number(spaces[0]?.quota_bytes ?? 0) -
        Number(spaces[0]?.used_bytes ?? 0) -
        Number(spaces[0]?.reserved_bytes ?? 0);
      if (available < htmlBytes) {
        throw publicationError('剩余空间不足', 'quota_exceeded', {
          requiredBytes: htmlBytes,
          availableBytes: Math.max(0, available),
        });
      }
      const [publicationUsage] = await conn.query(
        `SELECT COUNT(DISTINCT page_id) AS public_page_used,
                MAX(CASE WHEN page_id = ? THEN 1 ELSE 0 END) AS page_already_online
         FROM h5_publish_records
         WHERE user_id = ? AND status = 'online'`,
        [pageId, userId],
      );
      const publicPageLimit = await resolvePublicPageLimit();
      if (
        !Number(publicationUsage[0]?.page_already_online) &&
        Number(publicationUsage[0]?.public_page_used) >= publicPageLimit
      ) {
        throw publicationError('公开页面数量已达到套餐上限', 'public_page_limit_exceeded', {
          limit: publicPageLimit,
        });
      }
      const [categories] = await conn.query(
        `SELECT id FROM h5_space_categories
         WHERE user_id = ? AND space_id = ? AND category_code = 'public' LIMIT 1`,
        [userId, page.space_id],
      );
      if (!categories[0]) throw publicationError('公开分类不存在', 'category_not_found');

      const now = Date.now();
      const scanId = await persistScan(conn, userId, page.page_version_id, result, now);
      const bundleAssetId = idFactory();
      const assetVersionId = idFactory();
      const publishId = idFactory();
      const privateToken =
        result.accessMode === 'private_link'
          ? crypto.randomBytes(24).toString('base64url')
          : null;
      const tokenHash = privateToken
        ? crypto.createHash('sha256').update(privateToken).digest('hex')
        : null;
      const passwordHash =
        result.accessMode === 'password' ? hashPassword(input.password) : null;
      const storageKey = path.posix.join(
        'users',
        userId,
        'publications',
        publishId,
        'index.html',
      );
      writtenPath = absoluteStoragePath(storageKey);
      await fs.mkdir(path.dirname(writtenPath), { recursive: true });
      await fs.writeFile(writtenPath, html, { flag: 'wx' });
      const checksum = crypto.createHash('sha256').update(html).digest('hex');
      const publicBaseUrl = resolvePublicBaseUrl();
      const publicUrl = privateToken
        ? `/s/${privateToken}`
        : `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`;

      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 (?, ?, ?, ?, 'html', 'text/html', 'index.html', ?, ?, ?, ?, ?,
                 'public_candidate', 'ready', 'generated', ?, ?)`,
        [
          bundleAssetId,
          userId,
          page.space_id,
          categories[0].id,
          `${page.title} · 发布快照`,
          assetVersionId,
          htmlBytes,
          checksum,
          result.riskLevel,
          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, ?, ?, ?, 'text/html', ?, '发布页面快照', ?, ?)`,
        [
          assetVersionId,
          bundleAssetId,
          storageKey,
          htmlBytes,
          checksum,
          userId,
          result.status === 'passed' ? 'passed' : 'warned',
          now,
        ],
      );
      const [previousRows] = await conn.query(
        `SELECT id, page_version_id FROM h5_publish_records
         WHERE user_id = ? AND page_id = ?
         ORDER BY published_at DESC LIMIT 1 FOR UPDATE`,
        [userId, pageId],
      );
      await conn.query(
        `UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ?
         WHERE user_id = ? AND page_id = ? AND status = 'online'`,
        [now, now, userId, pageId],
      );
      await conn.query(
        `UPDATE h5_page_versions
         SET immutable = 1, bundle_asset_id = ?, security_scan_id = ?
         WHERE id = ? AND page_id = ?`,
        [bundleAssetId, scanId, page.page_version_id, pageId],
      );
      await conn.query(
        `INSERT INTO h5_publish_records
         (id, user_id, page_id, page_version_id, publish_type, url_slug, public_url,
          access_mode, password_hash, token_hash, token_prefix, expires_at, published_at,
          status, view_count, security_scan_id, created_at, updated_at)
         VALUES (?, ?, ?, ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?, 'online', 0, ?, ?, ?)`,
        [
          publishId,
          userId,
          pageId,
          page.page_version_id,
          result.urlSlug,
          publicUrl,
          result.accessMode,
          passwordHash,
          tokenHash,
          privateToken?.slice(0, 8) ?? null,
          result.expiresAt,
          now,
          scanId,
          now,
          now,
        ],
      );
      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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        [
          idFactory(),
          publishId,
          previousRows.length ? 'republished' : 'published',
          userId,
          previousRows[0]?.page_version_id ?? null,
          page.page_version_id,
          result.accessMode,
          JSON.stringify({ findings: result.findings.map((finding) => finding.id) }),
          now,
        ],
      );
      await conn.query(
        `UPDATE h5_page_records
         SET current_publish_id = ?, status = ?, visibility = ?, updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [
          publishId,
          result.accessMode === 'public' ? 'published' : 'protected',
          result.accessMode === 'public' ? 'public' : 'private',
          now,
          pageId,
          userId,
        ],
      );
      await conn.query(
        `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ?
         WHERE id = ? AND user_id = ?`,
        [htmlBytes, now, page.space_id, userId],
      );
      await conn.commit();
      return {
        ...publicationResponse({
          id: publishId,
          page_id: pageId,
          page_version_id: page.page_version_id,
          url_slug: result.urlSlug,
          public_url: publicUrl,
          access_mode: result.accessMode,
          status: 'online',
          view_count: 0,
          published_at: now,
          offline_at: null,
          expires_at: result.expiresAt,
        }),
        findings: result.findings,
      };
    } catch (error) {
      await conn.rollback();
      if (writtenPath) await fs.rm(writtenPath, { force: true }).catch(() => {});
      throw error;
    } finally {
      conn.release();
    }
  };

  const getCurrent = async (userId, pageId) => {
    const [rows] = await pool.query(
      `SELECT pr.* FROM h5_publish_records pr
       JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
       WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online'
       ORDER BY pr.published_at DESC LIMIT 1`,
      [pageId, userId],
    );
    return publicationResponse(rows[0]);
  };

  const offline = async (userId, publicationId) => {
    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();
      const [rows] = await conn.query(
        `SELECT * FROM h5_publish_records
         WHERE id = ? AND user_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`,
        [publicationId, userId],
      );
      const publication = rows[0];
      if (!publication) throw publicationError('发布记录不存在', 'publication_not_found');
      const now = Date.now();
      await conn.query(
        `UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ?
         WHERE id = ?`,
        [now, now, publicationId],
      );
      await conn.query(
        `UPDATE h5_page_records
         SET current_publish_id = NULL, status = 'offline', visibility = 'private', updated_at = ?
         WHERE id = ? AND user_id = ? AND current_publish_id = ?`,
        [now, publication.page_id, userId, publicationId],
      );
      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(),
          publicationId,
          userId,
          publication.page_version_id,
          publication.access_mode,
          now,
        ],
      );
      await conn.query(
        `UPDATE plaza_posts SET status = 'hidden', updated_at = ?
         WHERE publication_id = ? AND status != 'hidden'`,
        [now, publicationId],
      );
      await conn.commit();
      return { id: publicationId, status: 'offline', offlineAt: now };
    } catch (error) {
      await conn.rollback();
      throw error;
    } finally {
      conn.release();
    }
  };

  const resolveRow = async (row, viewerId, password, requestMeta = {}) => {
    if (!row) throw publicationError('公开页面不存在', 'publication_not_found');
    if (row.expires_at && Number(row.expires_at) <= Date.now()) {
      await pool.query(
        `UPDATE h5_publish_records SET status = 'expired', updated_at = ? WHERE id = ?`,
        [Date.now(), row.id],
      );
      throw publicationError('公开页面已过期', 'publication_not_found');
    }
    if (row.access_mode === 'owner_only' && row.owner_id !== viewerId) {
      throw publicationError('公开页面不存在', 'publication_not_found');
    }
    if (row.access_mode === 'login_required' && !viewerId) {
      throw publicationError('登录后才能访问此页面', 'publication_login_required');
    }
    if (row.access_mode === 'password' && !verifyPassword(password, row.password_hash)) {
      throw publicationError('需要访问密码', 'publication_password_required');
    }
    const now = Date.now();
    await pool.query(
      `UPDATE h5_publish_records SET view_count = view_count + 1, updated_at = ? WHERE id = ?`,
      [now, row.id],
    );
    await pool.query(
      `INSERT INTO h5_publication_views
       (id, publish_id, viewer_user_id, referrer_host, device_type, viewed_at)
       VALUES (?, ?, ?, ?, ?, ?)`,
      [
        idFactory(),
        row.id,
        viewerId ?? null,
        referrerHost(requestMeta.referrer),
        deviceType(requestMeta.userAgent),
        now,
      ],
    );
    return {
      html: await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'),
      publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }),
    };
  };

  const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => {
    const [rows] = await pool.query(
      `SELECT pr.*, u.id AS owner_id, av.storage_key
       FROM h5_publish_records pr
       JOIN h5_users u ON u.id = pr.user_id
       JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1
       JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1
       WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online'
         AND pr.access_mode <> 'private_link'
       ORDER BY pr.published_at DESC LIMIT 1`,
      [ownerSlug, urlSlug],
    );
    return resolveRow(rows[0], viewerId, password, requestMeta);
  };

  const resolvePrivateLink = async (token, viewerId, requestMeta) => {
    const tokenHash = crypto.createHash('sha256').update(String(token)).digest('hex');
    const [rows] = await pool.query(
      `SELECT pr.*, u.id AS owner_id, av.storage_key
       FROM h5_publish_records pr
       JOIN h5_users u ON u.id = pr.user_id
       JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1
       JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1
       WHERE pr.token_hash = ? AND pr.status = 'online' AND pr.access_mode = 'private_link'
       LIMIT 1`,
      [tokenHash],
    );
    return resolveRow(rows[0], viewerId, null, requestMeta);
  };

  const getStats = async (userId, publicationId) => {
    const [publications] = await pool.query(
      `SELECT id, view_count, published_at, status
       FROM h5_publish_records WHERE id = ? AND user_id = ? LIMIT 1`,
      [publicationId, userId],
    );
    const publication = publications[0];
    if (!publication) throw publicationError('发布记录不存在', 'publication_not_found');
    const since = Date.now() - 30 * 24 * 60 * 60 * 1000;
    const [daily] = await pool.query(
      `SELECT DATE_FORMAT(FROM_UNIXTIME(viewed_at / 1000), '%Y-%m-%d') AS day,
              COUNT(*) AS views
       FROM h5_publication_views
       WHERE publish_id = ? AND viewed_at >= ?
       GROUP BY day ORDER BY day`,
      [publicationId, since],
    );
    const [devices] = await pool.query(
      `SELECT device_type, COUNT(*) AS views
       FROM h5_publication_views WHERE publish_id = ?
       GROUP BY device_type ORDER BY views DESC`,
      [publicationId],
    );
    const [sources] = await pool.query(
      `SELECT COALESCE(referrer_host, 'direct') AS source, COUNT(*) AS views
       FROM h5_publication_views WHERE publish_id = ?
       GROUP BY source ORDER BY views DESC LIMIT 10`,
      [publicationId],
    );
    return {
      publicationId,
      status: publication.status,
      totalViews: Number(publication.view_count),
      publishedAt: Number(publication.published_at),
      daily: daily.map((row) => ({ day: row.day, views: Number(row.views) })),
      devices: devices.map((row) => ({
        deviceType: row.device_type,
        views: Number(row.views),
      })),
      sources: sources.map((row) => ({ source: row.source, views: Number(row.views) })),
    };
  };

  const getPublicHomepage = async (ownerSlug) => {
    const [owners] = await pool.query(
      `SELECT id, username, COALESCE(slug, username) AS slug, display_name
       FROM h5_users
       WHERE COALESCE(slug, username) = ?
       LIMIT 1`,
      [ownerSlug],
    );
    const owner = owners[0];
    if (!owner) throw publicationError('公开主页不存在', 'publication_not_found');
    const [rows] = await pool.query(
      `SELECT pr.id, pr.page_id, pr.url_slug, pr.public_url, pr.view_count, pr.published_at,
              p.title, p.summary, p.template_id
       FROM h5_publish_records pr
       JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
       WHERE pr.user_id = ? AND pr.status = 'online' AND pr.access_mode = 'public'
       ORDER BY pr.published_at DESC
       LIMIT 48`,
      [owner.id],
    );
    return publicHomepageResponse(owner, rows);
  };

  return {
    check,
    publish,
    getCurrent,
    getPublicHomepage,
    getStats,
    offline,
    resolvePublic,
    resolvePrivateLink,
  };
}

export const publicationInternals = {
  normalizeSlug,
  normalizeAccessMode,
  normalizePassword,
  normalizeExpiresAt,
  hashPassword,
  verifyPassword,
  deviceType,
  referrerHost,
  publicHomepageResponse,
  buildPublicationThumbnailFallback,
  localizePrivateImageReferences,
  prepareHtmlPublishContent,
  scanContent, // re-exported from mindspace-content-scan.mjs
};
