import crypto from 'node:crypto';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2';
import { computeDeltaCostCents, loadBillingConfig, normalizeTokenState } from './billing.mjs';
import { buildInsufficientBalancePayload, loadRechargeConfig } from './billing-recharge.mjs';
import {
  buildAgentExtensionPolicy,
  CAPABILITY_CATALOG,
  catalogKeys,
  clampUserCapabilities,
  DEFAULT_USER_CAPABILITIES,
  isValidCapabilityKey,
  normalizeCapabilityPatch,
  resolveSandboxMcpServerPath,
  USER_NON_GRANTABLE_CAPABILITIES,
} from './capabilities.mjs';
import {
  applyPoliciesToCapabilities,
  DEFAULT_USER_POLICIES,
  normalizePolicyPatch,
  POLICY_CATALOG,
  policyKeys,
  resolvePolicies,
} from './policies.mjs';
import {
  ensurePublishSkillInstalled,
  ensureUserPublishLayout,
  ensureWorkspaceHintsInstalled,
  PUBLISH_ROOT_DIR,
  PUBLISH_SKILL_NAME,
  resolvePublicBaseUrl,
  resolveLegacyPublishDir,
} from './user-publish.mjs';
import {
  ensureUserSpaceLayout,
  isPathInsideUserWorkspace,
  resolveMindspaceStorageRoot,
} from './user-space.mjs';
import { ensureUserMemoryProfile } from './user-memory-profile.mjs';
import {
  applySkillGrantsToCapabilities,
  DEFAULT_USER_SKILLS,
  grantedSkillNames,
  listPlatformSkillCatalog,
  normalizeSkillPatch,
  resolveSkillMap,
  syncSkillsToWorkspace,
} from './skills-registry.mjs';
import { initializeDefaultSpace } from './mindspace.mjs';

export const USER_COOKIE = 'tkmind_user_session';

function safeEqual(left, right) {
  const a = Buffer.from(left);
  const b = Buffer.from(right);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const PASSWORD_ALGORITHM_PBKDF2 = 'pbkdf2-sha512';
const PASSWORD_ALGORITHM_ARGON2ID = 'argon2id';
const ARGON2_MEMORY = 64 * 1024;
const ARGON2_PASSES = 3;
const ARGON2_PARALLELISM = 1;
const ARGON2_TAG_LENGTH = 32;

function hashPasswordPbkdf2(password, salt) {
  return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
}

function hashPasswordArgon2id(password, salt) {
  return argon2HashRawSync(password, {
    salt: Buffer.from(salt, 'hex'),
    parallelism: ARGON2_PARALLELISM,
    outputLen: ARGON2_TAG_LENGTH,
    memoryCost: ARGON2_MEMORY,
    timeCost: ARGON2_PASSES,
    algorithm: Argon2Algorithm.Argon2id,
  }).toString('hex');
}

function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) {
  const salt = crypto.randomBytes(16).toString('hex');
  if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
    return {
      salt,
      passwordHash: hashPasswordArgon2id(password, salt),
      passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID,
    };
  }
  return {
    salt,
    passwordHash: hashPasswordPbkdf2(password, salt),
    passwordAlgorithm: PASSWORD_ALGORITHM_PBKDF2,
  };
}

function verifyPassword(password, row) {
  const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2;
  if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
    return safeEqual(hashPasswordArgon2id(password, row.salt), row.password_hash);
  }
  return safeEqual(hashPasswordPbkdf2(password, row.salt), row.password_hash);
}

function normalizeUsername(username) {
  return username.trim().toLowerCase();
}

function isValidUsername(username) {
  return /^[a-z0-9_]{2,32}$/.test(username);
}

function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

function hashSessionToken(token) {
  return crypto.createHash('sha256').update(token).digest('hex');
}

export function createUserAuth(pool, options = {}) {
  const usersRoot = path.resolve(options.usersRoot ?? '/tmp/tkmind_go_users');
  const h5Root = path.resolve(options.h5Root ?? path.join(usersRoot, '..'));
  const env = options.env ?? process.env;
  const storageRoot = resolveMindspaceStorageRoot(h5Root, env);
  const publicBaseUrl = resolvePublicBaseUrl(env);
  const skillCatalog = listPlatformSkillCatalog(h5Root);
  const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
  const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000);
  const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
  const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000);
  const persistSessions = options.persistSessions !== false && Boolean(pool);
  let rechargeNotifier =
    typeof options.onRechargeNotification === 'function' ? options.onRechargeNotification : null;
  const subscriptionService = options.subscriptionService ?? null;
  const sessions = new Map();
  const loginFailures = new Map();

  const pruneLoginFailures = (now = Date.now()) => {
    for (const [key, state] of loginFailures) {
      if (state.resetAt <= now) loginFailures.delete(key);
    }
  };

  const pruneSessions = (now = Date.now()) => {
    for (const [token, session] of sessions) {
      if (session.expiresAt <= now) sessions.delete(token);
    }
  };

  const storeSession = async (userId, role, token, now = Date.now()) => {
    const expiresAt = now + sessionTtlMs;
    sessions.set(token, { userId, role, expiresAt });
    if (!persistSessions) return expiresAt;
    await pool.query(
      `INSERT INTO h5_login_sessions (id, user_id, token_hash, expires_at, created_at)
       VALUES (?, ?, ?, ?, ?)`,
      [crypto.randomUUID(), userId, hashSessionToken(token), expiresAt, now],
    );
    return expiresAt;
  };

  const revokeAllSessionsForUser = async (userId, now = Date.now()) => {
    for (const [token, session] of sessions) {
      if (session.userId === userId) sessions.delete(token);
    }
    if (!persistSessions) return;
    await pool.query(
      `UPDATE h5_login_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`,
      [now, userId],
    );
  };

  const ensureWorkspace = (workspaceRoot) => {
    fs.mkdirSync(workspaceRoot, { recursive: true });
  };

  const isAdminRole = (user) => user?.role === 'admin';

  const getUserById = async (userId) => {
    const [rows] = await pool.query(
      `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
              u.plan_type, u.workspace_root,
              s.quota_bytes, s.used_bytes, s.reserved_bytes,
              w.balance_cents, w.tokens_used,
              (SELECT COALESCE(SUM(ABS(amount_cents)), 0)
               FROM h5_billing_ledger l
               WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents
       FROM h5_users u
       LEFT JOIN h5_user_spaces s ON s.user_id = u.id
       LEFT JOIN h5_user_wallets w ON w.user_id = u.id
       WHERE u.id = ?`,
      [userId],
    );
    return rows[0] ?? null;
  };

  const publicUser = (row) => {
    const balanceCents = Number(row.balance_cents ?? 0);
    const spentCents = Number(row.spent_cents ?? 0);
    const base = {
      id: row.id,
      username: row.username,
      slug: row.slug ?? row.username,
      email: row.email ?? null,
      displayName: row.display_name,
      role: row.role,
      status: row.status,
      planType: row.plan_type ?? 'free',
      workspaceRoot: row.workspace_root,
      balanceCents,
      totalCreditCents: balanceCents + spentCents,
      tokensUsed: Number(row.tokens_used ?? 0),
      spaceQuotaBytes: Number(row.quota_bytes ?? 0),
      spaceUsedBytes: Number(row.used_bytes ?? 0),
      spaceReservedBytes: Number(row.reserved_bytes ?? 0),
      spaceAvailableBytes: Math.max(
        0,
        Number(row.quota_bytes ?? 0) - Number(row.used_bytes ?? 0) - Number(row.reserved_bytes ?? 0),
      ),
    };
    const publishKey = row.id;
    return {
      ...base,
      publishSlug: publishKey,
      publishUrl: `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/`,
      publishSkillName: PUBLISH_SKILL_NAME,
    };
  };

  const publishLayoutFor = async (user, { migrateLegacy = true } = {}) => {
    const web = ensureUserPublishLayout({
      h5Root,
      publicBaseUrl,
      user,
      legacyUsersRoot: migrateLegacy ? usersRoot : null,
    });
    const space = await ensureUserSpaceLayout({
      pool,
      storageRoot,
      userId: user.id,
      username: user.username ?? web.slug,
      displayName: user.displayName,
      publicBaseUrl,
      slug: web.slug,
      workspaceRoot: web.publishDir,
    });
    const hintsContext = {
      slug: web.slug,
      username: user.username ?? web.username,
      displayName: user.displayName,
      publicBaseUrl,
      publishDir: web.publishDir,
    };
    ensurePublishSkillInstalled(web.publishDir, hintsContext);
    ensureWorkspaceHintsInstalled(web.publishDir, hintsContext);
    const legacyPublishDir = resolveLegacyPublishDir(h5Root, user);
    if (legacyPublishDir && legacyPublishDir !== web.publishDir && fs.existsSync(legacyPublishDir)) {
      ensureWorkspaceHintsInstalled(legacyPublishDir, { ...hintsContext, publishDir: legacyPublishDir });
    }
    ensureUserMemoryProfile(web.publishDir, {
      userId: user.id,
      displayName: user.displayName ?? user.display_name,
      username: user.username ?? web.username,
      slug: web.slug,
    });
    return {
      ...web,
      ...space,
      publishDir: web.publishDir,
      constraints: web.constraints,
    };
  };

  const listSkillGrants = async (subjectType, subjectId) => {
    const [rows] = await pool.query(
      `SELECT skill_name, enabled
       FROM h5_user_skill_grants
       WHERE subject_type = ? AND subject_id = ?`,
      [subjectType, subjectId],
    );
    return Object.fromEntries(rows.map((row) => [row.skill_name, Boolean(row.enabled)]));
  };

  const resolveUserSkillMap = async (user) => {
    if (!user || user.role === 'admin') {
      return Object.fromEntries(skillCatalog.map((item) => [item.name, true]));
    }
    const roleDefaults = await listSkillGrants('role', 'user');
    const userOverrides = await listSkillGrants('user', user.id);
    return resolveSkillMap(roleDefaults, userOverrides, skillCatalog);
  };

  const syncUserSkillsForUser = async (user) => {
    if (!user || user.role === 'admin') return;
    const layout = await syncUserPublishWorkspace(user);
    const skillMap = await resolveUserSkillMap(user);
    syncSkillsToWorkspace({
      h5Root,
      publishDir: layout?.publishDir,
      skillMap,
      catalog: skillCatalog,
      user,
      publicBaseUrl,
    });
  };

  const syncUserPublishWorkspace = async (user) => {
    if (!user) return null;
    const layout = await publishLayoutFor(user);
    const current = path.resolve(user.workspace_root);
    const target = path.resolve(layout.publishDir);
    if (current !== target) {
      const now = Date.now();
      await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [
        layout.publishDir,
        now,
        user.id,
      ]);
      await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [user.id]);
      await pool.query(
        `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
        [user.id, layout.publishDir],
      );
    }
    return layout;
  };

  const recordSignupBonus = async (conn, userId, amountCents, now) => {
    const amount = Number(amountCents);
    if (!Number.isFinite(amount) || amount <= 0) return;
    await conn.query(
      `INSERT INTO h5_billing_ledger
       (user_id, type, amount_cents, tokens, note, operator_id, created_at)
       VALUES (?, 'adjust', ?, 0, '新用户赠送', NULL, ?)`,
      [userId, amount, now],
    );
  };

  const register = async ({ username, password, displayName, email }) => {
    const normalized = normalizeUsername(username);
    if (!isValidUsername(normalized)) {
      return { ok: false, message: '用户名仅支持 2-32 位小写字母、数字、下划线' };
    }
    if (!password || password.length < 6) {
      return { ok: false, message: '密码至少 6 位' };
    }
    if (!email || !isValidEmail(email.trim())) {
      return { ok: false, message: '请输入有效邮箱' };
    }

    const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
    const userId = crypto.randomUUID();
    const layout = await publishLayoutFor({ id: userId, username: normalized });
    const workspaceRoot = layout.publishDir;
    const now = Date.now();

    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();
      await conn.query(
        `INSERT INTO h5_users
         (id, username, slug, email, display_name, salt, password_hash, password_algorithm,
          role, status, plan_type, workspace_root, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, ?, ?)`,
        [
          userId,
          normalized,
          normalized,
          email?.trim().toLowerCase() || null,
          displayName?.trim() || normalized,
          salt,
          passwordHash,
          passwordAlgorithm,
          workspaceRoot,
          now,
          now,
        ],
      );
      await conn.query(
        `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
         VALUES (?, ?, 0, ?)`,
        [userId, defaultSignupBalanceCents, now],
      );
      await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now);
      await conn.query(
        `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
        [userId, workspaceRoot],
      );
      await initializeDefaultSpace(conn, userId, {
        quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
        now,
      });
      await conn.commit();
      ensureWorkspace(workspaceRoot);
      ensureUserMemoryProfile(workspaceRoot, {
        userId,
        displayName: displayName?.trim() || normalized,
        username: normalized,
        slug: normalized,
      });
      if (subscriptionService) {
        subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {});
      }
      const user = await getUserById(userId);
      return { ok: true, user: publicUser(user) };
    } catch (err) {
      await conn.rollback();
      if (err?.code === 'ER_DUP_ENTRY') {
        return { ok: false, message: '用户名、主页地址或邮箱已存在' };
      }
      throw err;
    } finally {
      conn.release();
    }
  };

  const login = async ({ username, password, ip = 'unknown', now = Date.now() }) => {
    pruneLoginFailures(now);
    const normalized = normalizeUsername(username);
    const failureKey = `${ip}:${normalized}`;
    const failure = loginFailures.get(failureKey);
    if (failure && failure.count >= loginMaxFailures && failure.resetAt > now) {
      return {
        ok: false,
        message: '尝试次数过多，请稍后再试',
        retryAfterMs: failure.resetAt - now,
      };
    }

    const [rows] = await pool.query(
      `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
              u.plan_type, u.workspace_root,
              u.salt, u.password_hash, u.password_algorithm, w.balance_cents, w.tokens_used
       FROM h5_users u
       LEFT JOIN h5_user_wallets w ON w.user_id = u.id
       WHERE u.username = ?`,
      [normalized],
    );
    const row = rows[0];
    if (!row) {
      const current =
        failure && failure.resetAt > now
          ? failure
          : { count: 0, resetAt: now + loginFailureWindowMs };
      current.count += 1;
      loginFailures.set(failureKey, current);
      return { ok: false, message: '用户名或密码错误' };
    }

    if (!verifyPassword(password, row)) {
      const current =
        failure && failure.resetAt > now
          ? failure
          : { count: 0, resetAt: now + loginFailureWindowMs };
      current.count += 1;
      loginFailures.set(failureKey, current);
      return { ok: false, message: '用户名或密码错误' };
    }
    if (row.status === 'disabled') {
      return { ok: false, message: '账户已禁用，请联系管理员' };
    }

    if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) {
      const nextPassword = createPasswordRecord(password);
      await pool.query(
        `UPDATE h5_users
         SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ?
         WHERE id = ?`,
        [nextPassword.salt, nextPassword.passwordHash, nextPassword.passwordAlgorithm, now, row.id],
      );
      row.salt = nextPassword.salt;
      row.password_hash = nextPassword.passwordHash;
      row.password_algorithm = nextPassword.passwordAlgorithm;
    }

    loginFailures.delete(failureKey);
    const token = crypto.randomBytes(32).toString('base64url');
    await storeSession(row.id, row.role, token, now);
    return { ok: true, token, user: publicUser(row) };
  };

  const resetPassword = async ({ username, email, password }) => {
    const normalized = normalizeUsername(username);
    if (!isValidUsername(normalized)) {
      return { ok: false, message: '用户名或邮箱不正确' };
    }
    if (!email || !isValidEmail(email.trim())) {
      return { ok: false, message: '请输入有效邮箱' };
    }
    if (!password || password.length < 6) {
      return { ok: false, message: '新密码至少 6 位' };
    }

    const [rows] = await pool.query(
      `SELECT id, email, status FROM h5_users WHERE username = ? LIMIT 1`,
      [normalized],
    );
    const row = rows[0];
    const normalizedEmail = email.trim().toLowerCase();
    if (!row || (row.email ?? '').toLowerCase() !== normalizedEmail) {
      return { ok: false, message: '用户名或邮箱不正确' };
    }
    if (row.status === 'disabled') {
      return { ok: false, message: '账户已禁用，请联系管理员' };
    }

    const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
    const now = Date.now();
    await pool.query(
      `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
      [salt, passwordHash, passwordAlgorithm, now, row.id],
    );
    await revokeAllSessionsForUser(row.id, now);
    return { ok: true };
  };

  const verify = async (token, now = Date.now()) => {
    if (!token) return null;
    pruneSessions(now);

    const cached = sessions.get(token);
    if (cached) {
      if (cached.expiresAt <= now) {
        sessions.delete(token);
        return null;
      }
      const user = await getUserById(cached.userId);
      if (!user || user.status === 'disabled') {
        await revoke(token, now);
        return null;
      }
      cached.expiresAt = now + sessionTtlMs;
      if (persistSessions) {
        await pool.query(
          `UPDATE h5_login_sessions
           SET expires_at = ?
           WHERE token_hash = ? AND revoked_at IS NULL`,
          [cached.expiresAt, hashSessionToken(token)],
        );
      }
      return cached;
    }

    if (!persistSessions) return null;

    const tokenHash = hashSessionToken(token);
    const [rows] = await pool.query(
      `SELECT s.user_id, s.expires_at, u.role, u.status
       FROM h5_login_sessions s
       JOIN h5_users u ON u.id = s.user_id
       WHERE s.token_hash = ? AND s.revoked_at IS NULL
       LIMIT 1`,
      [tokenHash],
    );
    const row = rows[0];
    if (!row || Number(row.expires_at ?? 0) <= now || row.status === 'disabled') {
      if (row) await revoke(token, now);
      return null;
    }

    const expiresAt = now + sessionTtlMs;
    await pool.query(
      `UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
      [expiresAt, tokenHash],
    );
    const session = { userId: row.user_id, role: row.role, expiresAt };
    sessions.set(token, session);
    return session;
  };

  const revoke = async (token, now = Date.now()) => {
    if (!token) return;
    sessions.delete(token);
    if (!persistSessions) return;
    await pool.query(
      `UPDATE h5_login_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
      [now, hashSessionToken(token)],
    );
  };

  const getMe = async (token) => {
    const session = await verify(token);
    if (!session) return null;
    const user = await getUserById(session.userId);
    if (!user) return null;
    return publicUser(user);
  };

  const listPathGrants = async (userId) => {
    const [rows] = await pool.query(
      `SELECT path, mode FROM h5_user_path_grants WHERE user_id = ? ORDER BY path`,
      [userId],
    );
    return rows.map((row) => ({ path: row.path, mode: row.mode }));
  };

  const resolveWorkingDir = async (userId) => {
    const user = await getUserById(userId);
    if (!user) throw new Error('用户不存在');
    const layout = await syncUserPublishWorkspace(user);
    return layout.publishDir;
  };

  const getUserPublishLayout = async (userId) => {
    const user = await getUserById(userId);
    if (!user) return null;
    return syncUserPublishWorkspace(user);
  };

  const isPathAllowed = async (userId, requestedPath) => {
    const user = await getUserById(userId);
    if (!user) return false;
    if (isAdminRole(user)) return true;
    const layout = await publishLayoutFor(user, { migrateLegacy: false });
    return isPathInsideUserWorkspace(layout.publishDir, requestedPath);
  };

  const repairAllUserPublishDirs = async () => {
    const [rows] = await pool.query(
      `SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`,
    );
    for (const row of rows) {
      await syncUserSkillsForUser(row);
    }
  };

  const seedRoleSkillDefaults = async () => {
    const now = Date.now();
    for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) {
      await pool.query(
        `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
         VALUES ('role', 'user', ?, ?, ?)
         ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
        [name, enabled ? 1 : 0, now],
      );
    }
  };

  const registerAgentSession = async (userId, agentSessionId, goosedNode = 0) => {
    await pool.query(
      `INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, created_at)
       VALUES (?, ?, ?, ?)
       ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), goosed_node = VALUES(goosed_node)`,
      [agentSessionId, userId, goosedNode, Date.now()],
    );
  };

  const getSessionNode = async (agentSessionId) => {
    const [rows] = await pool.query(
      `SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
      [agentSessionId],
    );
    return rows[0]?.goosed_node ?? 0;
  };

  const ownsSession = async (userId, agentSessionId) => {
    const [rows] = await pool.query(
      `SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`,
      [agentSessionId, userId],
    );
    return rows.length > 0;
  };

  const listOwnedSessionIds = async (userId) => {
    const [rows] = await pool.query(
      `SELECT agent_session_id FROM h5_user_sessions WHERE user_id = ?`,
      [userId],
    );
    return new Set(rows.map((row) => row.agent_session_id));
  };

  const unregisterAgentSession = async (userId, agentSessionId) => {
    await pool.query(
      `DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`,
      [agentSessionId, userId],
    );
  };

  const canUseChat = async (userId) => {
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };
    if (user.status === 'disabled') {
      return { ok: false, message: '账户已禁用' };
    }
    if (user.status === 'suspended' && !isAdminRole(user)) {
      return { ok: false, message: '账户已暂停' };
    }
    if (isAdminRole(user)) {
      return { ok: true, balanceCents: Number(user.balance_cents ?? 0) };
    }

    // Subscription check: active subscription with remaining quota bypasses balance requirement.
    if (subscriptionService) {
      const sub = await subscriptionService.getActiveSubscription(userId);
      if (sub) {
        const unlimited = sub.periodTokensLimit === 0;
        const hasQuota = unlimited || sub.periodTokensUsed < sub.periodTokensLimit;
        const balanceCents = Number(user.balance_cents ?? 0);
        if (hasQuota) {
          return { ok: true, balanceCents, subscription: sub };
        }
        // Quota exhausted — allow if balance covers overage, otherwise block.
        if (balanceCents > 0) {
          return { ok: true, balanceCents, subscription: sub, overQuota: true };
        }
        return {
          ok: false,
          message: '本月额度已用完，余额不足，请充值或升级套餐',
          ...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()),
        };
      }
    }

    const balanceCents = Number(user.balance_cents ?? 0);
    if (balanceCents <= 0) {
      return {
        ok: false,
        message: '余额不足，请充值后继续使用',
        ...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()),
      };
    }
    return { ok: true, balanceCents };
  };

  const listUsers = async ({ page = 1, pageSize = 20, search = '', role = '', status = '' } = {}) => {
    const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100);
    const safePage = Math.max(Number(page) || 1, 1);
    const offset = (safePage - 1) * safePageSize;
    const params = [];
    const clauses = [];
    if (search) {
      clauses.push('(u.username LIKE ? OR u.display_name LIKE ?)');
      params.push(`%${search}%`, `%${search}%`);
    }
    if (role) { clauses.push('u.role = ?'); params.push(role); }
    if (status) { clauses.push('u.status = ?'); params.push(status); }
    const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
    const [[{ total }]] = await pool.query(
      `SELECT COUNT(*) AS total FROM h5_users u ${where}`,
      params,
    );
    const [rows] = await pool.query(
      `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
              u.plan_type, u.workspace_root,
              s.quota_bytes, s.used_bytes, s.reserved_bytes,
              u.created_at, u.updated_at, w.balance_cents, w.tokens_used
       FROM h5_users u
       LEFT JOIN h5_user_spaces s ON s.user_id = u.id
       LEFT JOIN h5_user_wallets w ON w.user_id = u.id
       ${where}
       ORDER BY u.created_at DESC
       LIMIT ${safePageSize} OFFSET ${offset}`,
      params,
    );
    return {
      users: rows.map((row) => ({ ...publicUser(row), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) })),
      total: Number(total),
      page: safePage,
      pageSize: safePageSize,
    };
  };

  const getUserPublic = async (userId) => {
    const user = await getUserById(userId);
    return user ? publicUser(user) : null;
  };

  const createUser = async ({
    username,
    password,
    displayName,
    workspaceRoot,
    balanceCents,
    role = 'user',
    email,
  }) => {
    const normalized = normalizeUsername(username);
    if (!isValidUsername(normalized)) {
      return { ok: false, message: '用户名格式无效' };
    }
    if (!password || password.length < 6) {
      return { ok: false, message: '密码至少 6 位' };
    }

    const isAdmin = role === 'admin';
    const userId = crypto.randomUUID();
    const root = (await publishLayoutFor({ id: userId, username: normalized })).publishDir;
    const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
    const now = Date.now();

    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();
      await conn.query(
        `INSERT INTO h5_users
         (id, username, slug, email, display_name, salt, password_hash, password_algorithm,
          role, status, plan_type, workspace_root, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, ?)`,
        [
          userId,
          normalized,
          normalized,
          email?.trim().toLowerCase() || null,
          displayName?.trim() || normalized,
          salt,
          passwordHash,
          passwordAlgorithm,
          isAdmin ? 'admin' : 'user',
          root,
          now,
          now,
        ],
      );
      await conn.query(
        `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
         VALUES (?, ?, 0, ?)`,
        [userId, Number(balanceCents ?? defaultSignupBalanceCents), now],
      );
      await conn.query(
        `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
        [userId, root],
      );
      if (!isAdmin) {
        await initializeDefaultSpace(conn, userId, {
          quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
          now,
        });
      }
      await conn.commit();
      ensureWorkspace(root);
      if (subscriptionService && !isAdmin) {
        subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {});
      }
      const user = await getUserById(userId);
      return { ok: true, user: publicUser(user) };
    } catch (err) {
      await conn.rollback();
      if (err?.code === 'ER_DUP_ENTRY') {
        return { ok: false, message: '用户名已存在' };
      }
      throw err;
    } finally {
      conn.release();
    }
  };

  const updateUser = async (userId, patch) => {
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };

    const now = Date.now();
    const fields = [];
    const values = [];

    if (patch.displayName !== undefined) {
      fields.push('display_name = ?');
      values.push(patch.displayName.trim() || user.username);
    }
    if (patch.status !== undefined) {
      fields.push('status = ?');
      values.push(patch.status);
    }
    if (patch.workspaceRoot !== undefined) {
      const root = path.resolve(patch.workspaceRoot);
      fields.push('workspace_root = ?');
      values.push(root);
      ensureWorkspace(root);
      await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [userId]);
      await pool.query(
        `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
        [userId, root],
      );
    }
    if (patch.role !== undefined) {
      fields.push('role = ?');
      values.push(patch.role === 'admin' ? 'admin' : 'user');
    }

    if (fields.length > 0) {
      fields.push('updated_at = ?');
      values.push(now, userId);
      await pool.query(`UPDATE h5_users SET ${fields.join(', ')} WHERE id = ?`, values);
    }

    if (patch.status === 'disabled' || patch.status === 'suspended') {
      await revokeAllSessionsForUser(userId, now);
    }

    if (patch.balanceCents !== undefined) {
      await pool.query(
        `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
         VALUES (?, ?, 0, ?)
         ON DUPLICATE KEY UPDATE balance_cents = VALUES(balance_cents), updated_at = VALUES(updated_at)`,
        [userId, Number(patch.balanceCents), now],
      );
    }

    if (patch.spaceQuotaBytes !== undefined) {
      const quotaBytes = Math.floor(Number(patch.spaceQuotaBytes));
      if (!Number.isFinite(quotaBytes) || quotaBytes <= 0) {
        return { ok: false, message: '空间大小无效' };
      }
      const [spaceRows] = await pool.query(
        `SELECT id, quota_bytes, used_bytes, reserved_bytes
         FROM h5_user_spaces
         WHERE user_id = ?
         LIMIT 1`,
        [userId],
      );
      const currentSpace = spaceRows[0];
      const occupiedBytes = Number(currentSpace?.used_bytes ?? 0) + Number(currentSpace?.reserved_bytes ?? 0);
      if (quotaBytes < occupiedBytes) {
        return {
          ok: false,
          message: `空间不能小于已使用容量 ${Math.ceil(occupiedBytes / 1024 / 1024)} MB`,
        };
      }
      if (currentSpace?.id) {
        await pool.query(
          `UPDATE h5_user_spaces
           SET quota_bytes = ?, updated_at = ?
           WHERE user_id = ?`,
          [quotaBytes, now, userId],
        );
      } else {
        await initializeDefaultSpace(pool, userId, {
          quotaBytes,
          now,
        });
      }
    }

    const updated = await getUserById(userId);
    return { ok: true, user: publicUser(updated) };
  };

  const purchaseSpaceQuota = async (userId, sizeMb) => {
    const purchaseMb = Math.floor(Number(sizeMb));
    if (!Number.isFinite(purchaseMb) || purchaseMb <= 0) {
      return { ok: false, message: '扩容大小无效' };
    }

    const deltaBytes = purchaseMb * 1024 * 1024;
    const costCents = purchaseMb * 200;
    const now = Date.now();
    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();

      const [spaceRows] = await conn.query(
        `SELECT id, quota_bytes, used_bytes, reserved_bytes
         FROM h5_user_spaces
         WHERE user_id = ?
         LIMIT 1
         FOR UPDATE`,
        [userId],
      );
      if (!spaceRows[0]) {
        await initializeDefaultSpace(conn, userId, { now });
      }

      const [walletRows] = await conn.query(
        `SELECT balance_cents
         FROM h5_user_wallets
         WHERE user_id = ?
         FOR UPDATE`,
        [userId],
      );
      const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
      if (balanceCents < costCents) {
        await conn.rollback();
        return {
          ok: false,
          code: 'INSUFFICIENT_BALANCE',
          message: '余额不足，请先充值后再购买空间',
          balanceCents,
          minRechargeCents: Math.max(500, costCents - balanceCents),
          suggestedTiers: loadRechargeConfig().tiersCents,
        };
      }

      await conn.query(
        `UPDATE h5_user_wallets
         SET balance_cents = balance_cents - ?, updated_at = ?
         WHERE user_id = ?`,
        [costCents, now, userId],
      );
      await conn.query(
        `UPDATE h5_user_spaces
         SET quota_bytes = quota_bytes + ?, updated_at = ?
         WHERE user_id = ?`,
        [deltaBytes, now, userId],
      );
      await conn.query(
        `INSERT INTO h5_billing_ledger
         (user_id, type, amount_cents, tokens, note, operator_id, created_at)
         VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
        [userId, costCents, `space_purchase:${purchaseMb}MB`, now],
      );
      await conn.query(
        `INSERT INTO h5_user_notifications
         (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
         VALUES (?, ?, 'web', 'space_purchase', ?, ?, ?, 'unread', NULL, ?, ?)`,
        [
          crypto.randomUUID(),
          userId,
          '空间扩容成功',
          `已购买 ${purchaseMb} MB 空间，支付 ¥${(costCents / 100).toFixed(2)}。`,
          JSON.stringify({ purchaseMb, deltaBytes, costCents }),
          now,
          now,
        ],
      );

      await conn.commit();
      const [updatedSpaceRows] = await pool.query(
        `SELECT quota_bytes, used_bytes, reserved_bytes
         FROM h5_user_spaces
         WHERE user_id = ?
         LIMIT 1`,
        [userId],
      );
      const updatedSpace = updatedSpaceRows[0] ?? {};
      const updatedUser = await getUserById(userId);
      return {
        ok: true,
        balanceCents: Number(updatedUser?.balance_cents ?? Math.max(0, balanceCents - costCents)),
        quota: {
          quotaBytes: Number(updatedSpace.quota_bytes ?? 0),
          usedBytes: Number(updatedSpace.used_bytes ?? 0),
          reservedBytes: Number(updatedSpace.reserved_bytes ?? 0),
          availableBytes: Math.max(
            0,
            Number(updatedSpace.quota_bytes ?? 0)
              - Number(updatedSpace.used_bytes ?? 0)
              - Number(updatedSpace.reserved_bytes ?? 0),
          ),
        },
      };
    } catch (err) {
      await conn.rollback();
      throw err;
    } finally {
      conn.release();
    }
  };

  const recharge = async (userId, amountCents, operatorId, note = '', options = {}) => {
    const amount = Number(amountCents);
    if (!Number.isFinite(amount) || amount <= 0) {
      return { ok: false, message: '充值金额无效' };
    }
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };

    const paymentOrderId = options.paymentOrderId ?? null;
    const ledgerNote = paymentOrderId
      ? `order:${paymentOrderId}`
      : note || (operatorId ? '管理员充值' : '账户充值');
    const rechargeType = paymentOrderId ? 'self_recharge' : operatorId ? 'admin_recharge' : 'recharge';
    const now = Date.now();
    const ownsConnection = !options.conn;
    const conn = options.conn ?? (await pool.getConnection());
    try {
      if (ownsConnection) await conn.beginTransaction();
      await conn.query(
        `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
         VALUES (?, ?, 0, ?)
         ON DUPLICATE KEY UPDATE
           balance_cents = balance_cents + VALUES(balance_cents),
           updated_at = VALUES(updated_at)`,
        [userId, amount, now],
      );
      await conn.query(
        `INSERT INTO h5_billing_ledger
         (user_id, type, amount_cents, tokens, note, operator_id, created_at)
         VALUES (?, 'recharge', ?, 0, ?, ?, ?)`,
        [userId, amount, ledgerNote, operatorId, now],
      );
      const title = paymentOrderId ? '充值成功' : operatorId ? '管理员已充值' : '账户充值成功';
      const body = paymentOrderId
        ? `你已成功充值 ¥${(amount / 100).toFixed(2)}，余额已更新。`
        : operatorId
          ? `管理员已为你充值 ¥${(amount / 100).toFixed(2)}，余额已更新。`
          : `你的账户已充值 ¥${(amount / 100).toFixed(2)}，余额已更新。`;
      await conn.query(
        `INSERT INTO h5_user_notifications
         (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
         VALUES (?, ?, 'web', ?, ?, ?, ?, 'unread', NULL, ?, ?)`,
        [
          crypto.randomUUID(),
          userId,
          rechargeType,
          title,
          body,
          JSON.stringify({
            amountCents: amount,
            operatorId: operatorId ?? null,
            paymentOrderId,
          }),
          now,
          now,
        ],
      );
      if (user.status === 'suspended') {
        await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [
          now,
          userId,
        ]);
      }
      if (ownsConnection) await conn.commit();
      const updated = await getUserById(userId);
      if (rechargeNotifier) {
        try {
          await rechargeNotifier({
            userId,
            amountCents: amount,
            operatorId: operatorId ?? null,
            paymentOrderId,
            notificationType: rechargeType,
            title,
            body,
            user: publicUser(updated),
          });
        } catch (err) {
          console.warn(
            'Recharge notifier failed:',
            err instanceof Error ? err.message : String(err),
          );
        }
      }
      return { ok: true, user: publicUser(updated) };
    } catch (err) {
      if (ownsConnection) await conn.rollback();
      throw err;
    } finally {
      if (ownsConnection) conn.release();
    }
  };

  const getBillingState = async (agentSessionId) => {
    const [rows] = await pool.query(
      `SELECT agent_session_id, user_id, last_accumulated_cost, last_input_tokens,
              last_output_tokens, updated_at
       FROM h5_session_billing_state
       WHERE agent_session_id = ?`,
      [agentSessionId],
    );
    const row = rows[0];
    if (!row) return null;
    return {
      agentSessionId: row.agent_session_id,
      userId: row.user_id,
      lastAccumulatedCost: row.last_accumulated_cost,
      lastInputTokens: Number(row.last_input_tokens ?? 0),
      lastOutputTokens: Number(row.last_output_tokens ?? 0),
      updatedAt: Number(row.updated_at),
    };
  };

  const billSessionUsage = async (userId, agentSessionId, tokenStateRaw, requestId = null) => {
    const user = await getUserById(userId);
    if (isAdminRole(user)) {
      return {
        ok: true,
        costCents: 0,
        balanceCents: Number(user?.balance_cents ?? 0),
        tokensUsed: Number(user?.tokens_used ?? 0),
        deltaInputTokens: 0,
        deltaOutputTokens: 0,
      };
    }
    const tokenState = normalizeTokenState(tokenStateRaw);
    const previous = await getBillingState(agentSessionId);
    if (
      previous &&
      tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) &&
      tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0)
    ) {
      const user = await getUserById(userId);
      return {
        ok: true,
        costCents: 0,
        balanceCents: user ? Number(user.balance_cents) : null,
        tokensUsed: user ? Number(user.tokens_used ?? 0) : null,
        deltaInputTokens: 0,
        deltaOutputTokens: 0,
      };
    }
    const config = loadBillingConfig();
    let costCents = computeDeltaCostCents(previous, tokenState, config);
    const deltaIn = Math.max(
      0,
      tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0),
    );
    const deltaOut = Math.max(
      0,
      tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0),
    );
    const deltaTokens = deltaIn + deltaOut;

    const now = Date.now();
    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();

      await conn.query(
        `INSERT INTO h5_session_billing_state
         (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at)
         VALUES (?, ?, ?, ?, ?, ?)
         ON DUPLICATE KEY UPDATE
           last_accumulated_cost = VALUES(last_accumulated_cost),
           last_input_tokens = VALUES(last_input_tokens),
           last_output_tokens = VALUES(last_output_tokens),
           updated_at = VALUES(updated_at)`,
        [
          agentSessionId,
          userId,
          tokenState.accumulatedCost,
          tokenState.accumulatedInputTokens,
          tokenState.accumulatedOutputTokens,
          now,
        ],
      );

      // Subscription quota check: consume tokens from active plan before touching balance.
      if (costCents > 0 && subscriptionService) {
        const coverage = await subscriptionService.consumeQuota(userId, deltaTokens, conn);
        if (coverage.fullyCovers) {
          costCents = 0;
        } else if (coverage.overageRate < 1.0) {
          costCents = Math.max(1, Math.ceil(costCents * coverage.overageRate));
        }
      }

      let balanceAfter = null;
      let tokensUsedAfter = null;
      if (costCents > 0) {
        const [walletRows] = await conn.query(
          `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
          [userId],
        );
        const wallet = walletRows[0];
        if (!wallet) {
          await conn.rollback();
          return { ok: false, message: '钱包不存在', costCents: 0 };
        }

        const currentBalance = Number(wallet.balance_cents ?? 0);
        const nextBalance = Math.max(0, currentBalance - costCents);
        balanceAfter = nextBalance;
        tokensUsedAfter = Number(wallet.tokens_used ?? 0) + deltaTokens;

        await conn.query(
          `UPDATE h5_user_wallets
           SET balance_cents = ?, tokens_used = tokens_used + ?, updated_at = ?
           WHERE user_id = ?`,
          [nextBalance, deltaTokens, now, userId],
        );

        await conn.query(
          `INSERT INTO h5_usage_records
           (user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, created_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
          [userId, agentSessionId, requestId, deltaIn, deltaOut, costCents, nextBalance, now],
        );

        await conn.query(
          `INSERT INTO h5_billing_ledger
           (user_id, type, amount_cents, tokens, session_id, note, operator_id, created_at)
           VALUES (?, 'deduct', ?, ?, ?, ?, NULL, ?)`,
          [
            userId,
            -costCents,
            deltaTokens,
            agentSessionId,
            `对话扣费 input=${deltaIn} output=${deltaOut}`,
            now,
          ],
        );

        if (nextBalance <= 0) {
          await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [
            now,
            userId,
          ]);
        }
      } else {
        const user = await getUserById(userId);
        balanceAfter = user ? Number(user.balance_cents) : null;
        tokensUsedAfter = user ? Number(user.tokens_used ?? 0) : null;
      }

      await conn.commit();
      return {
        ok: true,
        costCents,
        balanceCents: balanceAfter,
        tokensUsed: tokensUsedAfter,
        deltaInputTokens: deltaIn,
        deltaOutputTokens: deltaOut,
      };
    } catch (err) {
      await conn.rollback();
      throw err;
    } finally {
      conn.release();
    }
  };

  const listUsageRecords = async ({ userId = null, page = 1, pageSize = 20, limit = null } = {}) => {
    // legacy: if limit is passed (from dashboard summary), skip pagination
    if (limit !== null) {
      const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
      const params = [];
      const where = userId ? 'WHERE r.user_id = ?' : '';
      if (userId) params.push(userId);
      const [rows] = await pool.query(
        `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
                r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
         FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
         ${where} ORDER BY r.created_at DESC LIMIT ${safeLimit}`,
        params,
      );
      return rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) }));
    }
    const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
    const safePage = Math.max(Number(page) || 1, 1);
    const offset = (safePage - 1) * safePageSize;
    const params = [];
    const where = userId ? 'WHERE r.user_id = ?' : '';
    if (userId) params.push(userId);
    const [[{ total }]] = await pool.query(
      `SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
      params,
    );
    const [rows] = await pool.query(
      `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
              r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
       FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
       ${where}
       ORDER BY r.created_at DESC
       LIMIT ${safePageSize} OFFSET ${offset}`,
      params,
    );
    return {
      records: rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })),
      total: Number(total),
      page: safePage,
      pageSize: safePageSize,
    };
  };

  const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types = null } = {}) => {
    const buildWhere = (params) => {
      const clauses = [];
      if (userId) { clauses.push('l.user_id = ?'); params.push(userId); }
      if (Array.isArray(types) && types.length) {
        clauses.push(`l.type IN (${types.map(() => '?').join(', ')})`);
        params.push(...types);
      }
      return clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
    };
    const mapRow = (row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, type: row.type, amountCents: Number(row.amount_cents), tokens: Number(row.tokens), sessionId: row.session_id, note: row.note, createdAt: Number(row.created_at) });
    // legacy: if limit is passed (from dashboard summary), skip pagination
    if (limit !== null) {
      const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
      const params = [];
      const where = buildWhere(params);
      const [rows] = await pool.query(`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${where} ORDER BY l.created_at DESC LIMIT ${safeLimit}`, params);
      return rows.map(mapRow);
    }
    const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
    const safePage = Math.max(Number(page) || 1, 1);
    const offset = (safePage - 1) * safePageSize;
    const countParams = [];
    const where = buildWhere(countParams);
    const [[{ total }]] = await pool.query(
      `SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`,
      countParams,
    );
    const dataParams = [];
    const dataWhere = buildWhere(dataParams);
    const [rows] = await pool.query(
      `SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens,
              l.session_id, l.note, l.created_at
       FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id
       ${dataWhere}
       ORDER BY l.created_at DESC
       LIMIT ${safePageSize} OFFSET ${offset}`,
      dataParams,
    );
    return { entries: rows.map(mapRow), total: Number(total), page: safePage, pageSize: safePageSize };
  };

  const getAdminSummary = async () => {
    const since24h = Date.now() - 24 * 60 * 60 * 1000;
    const [userRows] = await pool.query(
      `SELECT u.id, u.username, u.display_name, u.role, u.status,
              COALESCE(w.balance_cents, 0) AS balance_cents
       FROM h5_users u
       LEFT JOIN h5_user_wallets w ON w.user_id = u.id`,
    );

    let total = 0;
    let active = 0;
    let lowBalance = 0;
    let totalBalanceCents = 0;
    const lowBalanceUsers = [];

    for (const row of userRows) {
      total += 1;
      if (row.status === 'active') active += 1;
      const balanceCents = Number(row.balance_cents);
      totalBalanceCents += balanceCents;
      if (row.role === 'user' && balanceCents <= 0) {
        lowBalance += 1;
        if (lowBalanceUsers.length < 8) {
          lowBalanceUsers.push({
            id: row.id,
            username: row.username,
            displayName: row.display_name,
            balanceCents,
          });
        }
      }
    }

    const [[usage24h]] = await pool.query(
      `SELECT COUNT(*) AS count, COALESCE(SUM(cost_cents), 0) AS cost_cents
       FROM h5_usage_records
       WHERE created_at >= ?`,
      [since24h],
    );

    const recentUsage = await listUsageRecords({ limit: 8 });
    const recentLedger = await listBillingLedger({ limit: 8 });

    return {
      users: { total, active, lowBalance, totalBalanceCents },
      usage24h: {
        count: Number(usage24h.count),
        costCents: Number(usage24h.cost_cents),
      },
      lowBalanceUsers,
      recentUsage,
      recentLedger,
    };
  };

  const syncAdminPassword = async () => {
    const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? 'admin');
    const adminPassword = process.env.H5_ADMIN_PASSWORD;
    if (!adminPassword) return;

    const [rows] = await pool.query(
      `SELECT id FROM h5_users WHERE username = ? AND role = 'admin' LIMIT 1`,
      [adminUsername],
    );
    if (rows.length === 0) return;

    const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword);
    const now = Date.now();
    await pool.query(
      `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
      [salt, passwordHash, passwordAlgorithm, now, rows[0].id],
    );
  };

  const seedRoleCapabilityDefaults = async () => {
    const now = Date.now();
    for (const [key, allowed] of Object.entries(DEFAULT_USER_CAPABILITIES)) {
      await pool.query(
        `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
         VALUES ('role', 'user', ?, ?, ?)
         ON DUPLICATE KEY UPDATE capability_key = capability_key`,
        [key, allowed ? 1 : 0, now],
      );
    }
  };

  /** Enable L3 memory_store for existing role defaults without touching per-user overrides. */
  const upgradeMemoryStoreCapability = async () => {
    const now = Date.now();
    await pool.query(
      `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
       VALUES ('role', 'user', 'memory_store', 1, ?)
       ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`,
      [now],
    );
  };

  /** Enable platform skill loading + chat recall for existing role defaults. */
  const upgradeDefaultUserCapabilities = async () => {
    const now = Date.now();
    for (const key of ['skills', 'chat_recall']) {
      if (!DEFAULT_USER_CAPABILITIES[key]) continue;
      await pool.query(
        `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
         VALUES ('role', 'user', ?, 1, ?)
         ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`,
        [key, now],
      );
    }
  };

  /** Enable default platform skills for existing role defaults. */
  const upgradeDefaultUserSkills = async () => {
    const now = Date.now();
    for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) {
      if (!enabled) continue;
      await pool.query(
        `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
         VALUES ('role', 'user', ?, 1, ?)
         ON DUPLICATE KEY UPDATE enabled = 1, updated_at = VALUES(updated_at)`,
        [name, now],
      );
    }
  };

  const serializePolicyValue = (key, value) => {
    const def = POLICY_CATALOG.find((item) => item.key === key);
    if (def?.type === 'boolean') return value ? 'true' : 'false';
    return String(value);
  };

  const parsePolicyValue = (key, raw) => {
    const def = POLICY_CATALOG.find((item) => item.key === key);
    if (def?.type === 'boolean') return raw === 'true' || raw === '1';
    return raw;
  };

  const seedRolePolicyDefaults = async () => {
    const now = Date.now();
    for (const [key, value] of Object.entries(DEFAULT_USER_POLICIES)) {
      await pool.query(
        `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
         VALUES ('role', 'user', ?, ?, ?)
         ON DUPLICATE KEY UPDATE policy_key = policy_key`,
        [key, serializePolicyValue(key, value), now],
      );
    }
  };

  const listPolicyEntries = async (subjectType, subjectId) => {
    const [rows] = await pool.query(
      `SELECT policy_key, policy_value
       FROM h5_user_policies
       WHERE subject_type = ? AND subject_id = ?`,
      [subjectType, subjectId],
    );
    return Object.fromEntries(
      rows.map((row) => [row.policy_key, parsePolicyValue(row.policy_key, row.policy_value)]),
    );
  };

  const resolveUserPolicies = async (user) => {
    if (!user || user.role === 'admin') {
      return { unrestricted: true, policies: {} };
    }
    const roleDefaults = await listPolicyEntries('role', 'user');
    const userOverrides = await listPolicyEntries('user', user.id);
    return {
      unrestricted: false,
      policies: resolvePolicies(roleDefaults, userOverrides),
    };
  };

  const listCapabilityGrants = async (subjectType, subjectId) => {
    const [rows] = await pool.query(
      `SELECT capability_key, allowed
       FROM h5_capability_grants
       WHERE subject_type = ? AND subject_id = ?`,
      [subjectType, subjectId],
    );
    return Object.fromEntries(
      rows.map((row) => [row.capability_key, Boolean(row.allowed)]),
    );
  };

  const resolveUserCapabilities = async (user) => {
    if (!user) return { unrestricted: true, capabilities: {} };
    if (user.role === 'admin') {
      const skillMap = Object.fromEntries(skillCatalog.map((item) => [item.name, true]));
      return {
        unrestricted: true,
        capabilities: Object.fromEntries(catalogKeys().map((key) => [key, true])),
        skills: skillMap,
        grantedSkills: grantedSkillNames(skillMap),
      };
    }

    const roleDefaults = await listCapabilityGrants('role', 'user');
    const userOverrides = await listCapabilityGrants('user', user.id);
    const capabilities = {};
    for (const key of catalogKeys()) {
      if (key in userOverrides) {
        capabilities[key] = userOverrides[key];
      } else if (key in roleDefaults) {
        capabilities[key] = roleDefaults[key];
      } else {
        capabilities[key] = DEFAULT_USER_CAPABILITIES[key] ?? false;
      }
    }
    const skillMap = await resolveUserSkillMap(user);
    const withSkills = applySkillGrantsToCapabilities(capabilities, skillMap);
    return {
      unrestricted: false,
      capabilities: clampUserCapabilities(withSkills),
      skills: skillMap,
      grantedSkills: grantedSkillNames(skillMap),
    };
  };

  const getAgentSessionPolicy = async (userId) => {
    const user = await getUserById(userId);
    if (!user) throw new Error('用户不存在');
    const capabilityState = await resolveUserCapabilities(user);
    const policyState = await resolveUserPolicies(user);
    await syncUserSkillsForUser(user);
    if (capabilityState.unrestricted) {
      return {
        ...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true }),
        policies: {},
        unrestricted: true,
      };
    }
    const effectiveCapabilities = applyPoliciesToCapabilities(
      capabilityState.capabilities,
      policyState.policies,
    );
    // Wire up the sandbox MCP for user workspace tools and the private data space.
    // File operations are OS-bound to the user's workspace; private_data_* tools
    // only touch the user's single SQLite data space inside that workspace.
    let sandboxMcp = null;
    if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) {
      try {
        const layout = await publishLayoutFor(user, { migrateLegacy: false });
        sandboxMcp = {
          serverPath: resolveSandboxMcpServerPath(),
          sandboxRoot: layout.publishDir,
          userId: user.id,
        };
      } catch (err) {
        console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message);
      }
    }
    return {
      ...buildAgentExtensionPolicy(effectiveCapabilities, {
        unrestricted: false,
        policies: policyState.policies,
        sandboxMcp,
      }),
      capabilities: effectiveCapabilities,
      policies: policyState.policies,
      unrestricted: false,
    };
  };

  const getRoleCapabilities = async (role = 'user') => {
    const roleDefaults = await listCapabilityGrants('role', role);
    const capabilities = {};
    for (const key of catalogKeys()) {
      capabilities[key] =
        key in roleDefaults ? roleDefaults[key] : (DEFAULT_USER_CAPABILITIES[key] ?? false);
    }
    return { role, capabilities: clampUserCapabilities(capabilities) };
  };

  const setRoleCapabilities = async (role, patch) => {
    if (role !== 'user') {
      return { ok: false, message: '仅支持配置普通用户角色默认权限' };
    }
    const normalized = normalizeCapabilityPatch(patch);
    const now = Date.now();
    for (const [key, allowed] of Object.entries(normalized)) {
      const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed;
      await pool.query(
        `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
         VALUES ('role', ?, ?, ?, ?)
         ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
        [role, key, effectiveAllowed ? 1 : 0, now],
      );
    }
    return { ok: true, ...(await getRoleCapabilities(role)) };
  };

  const getUserCapabilities = async (userId) => {
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };
    const resolved = await resolveUserCapabilities(user);
    const overrides = await listCapabilityGrants('user', userId);
    return {
      ok: true,
      userId,
      role: user.role,
      unrestricted: resolved.unrestricted,
      capabilities: resolved.capabilities,
      overrides,
    };
  };

  const setUserCapabilities = async (userId, patch) => {
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };
    if (user.role === 'admin') {
      return { ok: false, message: '管理员不受能力限制' };
    }
    const normalized = normalizeCapabilityPatch(patch);
    const now = Date.now();
    for (const [key, allowed] of Object.entries(normalized)) {
      if (!isValidCapabilityKey(key)) continue;
      const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed;
      await pool.query(
        `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
         VALUES ('user', ?, ?, ?, ?)
         ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
        [userId, key, effectiveAllowed ? 1 : 0, now],
      );
    }
    return getUserCapabilities(userId);
  };

  const clearUserCapabilityOverrides = async (userId) => {
    await pool.query(
      `DELETE FROM h5_capability_grants WHERE subject_type = 'user' AND subject_id = ?`,
      [userId],
    );
    return getUserCapabilities(userId);
  };

  const getRolePolicies = async (role = 'user') => {
    const roleDefaults = await listPolicyEntries('role', role);
    const policies = resolvePolicies(roleDefaults, {});
    return { role, policies };
  };

  const setRolePolicies = async (role, patch) => {
    if (role !== 'user') {
      return { ok: false, message: '仅支持配置普通用户角色默认策略' };
    }
    const normalized = normalizePolicyPatch(patch);
    const now = Date.now();
    for (const [key, value] of Object.entries(normalized)) {
      await pool.query(
        `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
         VALUES ('role', ?, ?, ?, ?)
         ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`,
        [role, key, serializePolicyValue(key, value), now],
      );
    }
    return { ok: true, ...(await getRolePolicies(role)) };
  };

  const getUserPolicies = async (userId) => {
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };
    const policyState = await resolveUserPolicies(user);
    const overrides = await listPolicyEntries('user', userId);
    return {
      ok: true,
      userId,
      role: user.role,
      unrestricted: policyState.unrestricted,
      policies: policyState.policies,
      overrides,
    };
  };

  const setUserPolicies = async (userId, patch) => {
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };
    if (user.role === 'admin') {
      return { ok: false, message: '管理员不受策略限制' };
    }
    const normalized = normalizePolicyPatch(patch);
    const now = Date.now();
    for (const [key, value] of Object.entries(normalized)) {
      if (!policyKeys().includes(key)) continue;
      await pool.query(
        `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at)
         VALUES ('user', ?, ?, ?, ?)
         ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`,
        [userId, key, serializePolicyValue(key, value), now],
      );
    }
    return getUserPolicies(userId);
  };

  const clearUserPolicyOverrides = async (userId) => {
    await pool.query(`DELETE FROM h5_user_policies WHERE subject_type = 'user' AND subject_id = ?`, [
      userId,
    ]);
    return getUserPolicies(userId);
  };

  const getRoleSkills = async (role = 'user') => {
    const roleDefaults = await listSkillGrants('role', role);
    const skills = resolveSkillMap(roleDefaults, {}, skillCatalog);
    return { role, skills };
  };

  const setRoleSkills = async (role, patch) => {
    if (role !== 'user') {
      return { ok: false, message: '仅支持配置普通用户角色默认技能' };
    }
    const normalized = normalizeSkillPatch(skillCatalog, patch);
    const now = Date.now();
    for (const [name, enabled] of Object.entries(normalized)) {
      await pool.query(
        `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
         VALUES ('role', ?, ?, ?, ?)
         ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
        [role, name, enabled ? 1 : 0, now],
      );
    }
    const [users] = await pool.query(`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`);
    for (const row of users) {
      await syncUserSkillsForUser(row);
    }
    return { ok: true, ...(await getRoleSkills(role)) };
  };

  const getUserSkills = async (userId) => {
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };
    const skills = await resolveUserSkillMap(user);
    const overrides = await listSkillGrants('user', userId);
    return {
      ok: true,
      userId,
      role: user.role,
      skills,
      grantedSkills: grantedSkillNames(skills),
      overrides,
    };
  };

  const setUserSkills = async (userId, patch) => {
    const user = await getUserById(userId);
    if (!user) return { ok: false, message: '用户不存在' };
    if (user.role === 'admin') {
      return { ok: false, message: '管理员不受技能限制' };
    }
    const normalized = normalizeSkillPatch(skillCatalog, patch);
    const now = Date.now();
    for (const [name, enabled] of Object.entries(normalized)) {
      await pool.query(
        `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
         VALUES ('user', ?, ?, ?, ?)
         ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
        [userId, name, enabled ? 1 : 0, now],
      );
    }
    await syncUserSkillsForUser(user);
    return getUserSkills(userId);
  };

  const clearUserSkillOverrides = async (userId) => {
    await pool.query(`DELETE FROM h5_user_skill_grants WHERE subject_type = 'user' AND subject_id = ?`, [
      userId,
    ]);
    const user = await getUserById(userId);
    if (user) await syncUserSkillsForUser(user);
    return getUserSkills(userId);
  };

  const ensureAdminUser = async () => {
    const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? 'admin');
    const adminPassword = process.env.H5_ADMIN_PASSWORD;

    const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
      adminUsername,
    ]);
    if (rows.length === 0) {
      if (!adminPassword) return;
      await createUser({
        username: adminUsername,
        password: adminPassword,
        displayName: '管理员',
        balanceCents: 9_999_999_99,
        role: 'admin',
      });
    } else {
      const adminId = rows[0].id;
      const now = Date.now();
      const adminLayout = await publishLayoutFor({
        id: adminId,
        username: adminUsername,
        displayName: '管理员',
      });
      await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [
        adminLayout.publishDir,
        now,
        adminId,
      ]);
      await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]);
      await pool.query(
        `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
        [adminId, adminLayout.publishDir],
      );
      await pool.query(
        `UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ?`,
        [9_999_999_99, now, adminId],
      );
    }
    if (adminPassword) {
      await syncAdminPassword();
    }
    await seedRoleCapabilityDefaults();
    await upgradeMemoryStoreCapability();
    await upgradeDefaultUserCapabilities();
    await seedRolePolicyDefaults();
    await seedRoleSkillDefaults();
    await upgradeDefaultUserSkills();
    await repairAllUserPublishDirs();
  };

  const PENDING_BIND_TTL_MS = 15 * 60 * 1000;

  const issueUserSession = async (userId, role, now = Date.now()) => {
    const token = crypto.randomBytes(32).toString('base64url');
    await storeSession(userId, role, token, now);
    return token;
  };

  const findBindingByOpenid = async (appId, openid) => {
    const [rows] = await pool.query(
      `SELECT wi.user_id, u.status
       FROM h5_user_wechat_identities wi
       JOIN h5_users u ON u.id = wi.user_id
       WHERE wi.app_id = ? AND wi.openid = ?
       LIMIT 1`,
      [appId, openid],
    );
    return rows[0] ?? null;
  };

  const findBindingByUnionid = async (unionid) => {
    if (!unionid) return null;
    const [rows] = await pool.query(
      `SELECT wi.user_id, wi.app_id, u.status
       FROM h5_user_wechat_identities wi
       JOIN h5_users u ON u.id = wi.user_id
       WHERE wi.unionid = ?
       LIMIT 1`,
      [unionid],
    );
    return rows[0] ?? null;
  };

  const getWechatBindingForUser = async (userId, appId) => {
    const [rows] = await pool.query(
      `SELECT id, nickname, avatar_url, last_login_at, created_at
       FROM h5_user_wechat_identities
       WHERE user_id = ? AND app_id = ?
       LIMIT 1`,
      [userId, appId],
    );
    return rows[0] ?? null;
  };

  const getWechatOpenidForUser = async (userId, appId) => {
    const [rows] = await pool.query(
      `SELECT openid
       FROM h5_user_wechat_identities
       WHERE user_id = ? AND app_id = ?
       LIMIT 1`,
      [userId, appId],
    );
    return rows[0]?.openid ?? null;
  };

  const findWechatUserByOpenid = async (appId, openid) => {
    const [rows] = await pool.query(
      `SELECT wi.user_id, wi.nickname, u.username, u.slug, u.display_name, u.status
       FROM h5_user_wechat_identities wi
       JOIN h5_users u ON u.id = wi.user_id
       WHERE wi.app_id = ? AND wi.openid = ?
       LIMIT 1`,
      [appId, openid],
    );
    return rows[0]
      ? {
          userId: rows[0].user_id,
          status: rows[0].status,
          nickname: rows[0].nickname,
          username: rows[0].username,
          slug: rows[0].slug,
          displayName: rows[0].display_name,
        }
      : null;
  };

  const getWechatAgentRoute = async (appId, openid) => {
    const [rows] = await pool.query(
      `SELECT id, user_id, agent_session_id, status, created_at, updated_at
       FROM h5_wechat_agent_routes
       WHERE app_id = ? AND openid = ?
       LIMIT 1`,
      [appId, openid],
    );
    const row = rows[0];
    if (!row) return null;
    return {
      id: row.id,
      userId: row.user_id,
      agentSessionId: row.agent_session_id,
      status: row.status,
      createdAt: Number(row.created_at ?? 0),
      updatedAt: Number(row.updated_at ?? 0),
    };
  };

  const upsertWechatAgentRoute = async ({
    userId,
    appId,
    openid,
    agentSessionId,
    status = 'active',
    now = Date.now(),
  }) => {
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO h5_wechat_agent_routes
       (id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)
       ON DUPLICATE KEY UPDATE
         user_id = VALUES(user_id),
         agent_session_id = VALUES(agent_session_id),
         status = VALUES(status),
         updated_at = VALUES(updated_at)`,
      [id, userId, appId, openid, agentSessionId, status, now, now],
    );
    const route = await getWechatAgentRoute(appId, openid);
    return route?.id ?? id;
  };

  const clearWechatAgentRoute = async (appId, openid) => {
    await pool.query(`DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ?`, [
      appId,
      openid,
    ]);
  };

  const recordWechatMpMessage = async ({
    appId,
    openid,
    msgId,
    now = Date.now(),
  }) => {
    if (!appId || !openid || !msgId) return { inserted: true };
    const [result] = await pool.query(
      `INSERT IGNORE INTO h5_wechat_mp_messages
       (app_id, openid, msg_id, status, created_at, updated_at)
       VALUES (?, ?, ?, 'processing', ?, ?)`,
      [appId, openid, String(msgId), now, now],
    );
    if (Number(result?.affectedRows ?? 0) > 0) return { inserted: true };

    const retryCutoff = now - 10 * 60 * 1000;
    const [retryResult] = await pool.query(
      `UPDATE h5_wechat_mp_messages
       SET status = 'processing', agent_session_id = NULL, updated_at = ?
       WHERE app_id = ? AND openid = ? AND msg_id = ?
         AND (status = 'failed' OR (status = 'processing' AND updated_at < ?))`,
      [now, appId, openid, String(msgId), retryCutoff],
    );
    return {
      inserted: Number(retryResult?.affectedRows ?? 0) > 0,
      duplicate: Number(retryResult?.affectedRows ?? 0) === 0,
    };
  };

  const finishWechatMpMessage = async ({
    appId,
    openid,
    msgId,
    status = 'done',
    agentSessionId = null,
    now = Date.now(),
  }) => {
    if (!appId || !openid || !msgId) return;
    const safeStatus = status === 'failed' ? 'failed' : 'done';
    await pool.query(
      `UPDATE h5_wechat_mp_messages
       SET status = ?, agent_session_id = COALESCE(?, agent_session_id), updated_at = ?
       WHERE app_id = ? AND openid = ? AND msg_id = ?`,
      [safeStatus, agentSessionId, now, appId, openid, String(msgId)],
    );
  };

  const insertWechatMpMessageDetail = async ({
    appId,
    openid,
    userId = null,
    msgId = null,
    msgType,
    displayText = '',
    agentText = '',
    mediaId = null,
    mediaUrl = null,
    mediaPublicUrl = null,
    mediaFormat = null,
    locationLat = null,
    locationLng = null,
    locationLabel = null,
    linkUrl = null,
    linkTitle = null,
    rawXmlHash = null,
    rawJson = null,
    now = Date.now(),
  }) => {
    if (!appId || !openid || !msgType) return null;
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO h5_wechat_mp_message_details
       (id, app_id, openid, user_id, msg_id, msg_type, display_text, agent_text,
        media_id, media_url, media_public_url, media_format,
        location_lat, location_lng, location_label,
        link_url, link_title, raw_xml_hash, raw_json, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        id,
        appId,
        openid,
        userId,
        msgId ? String(msgId) : null,
        String(msgType),
        displayText || null,
        agentText || null,
        mediaId,
        mediaUrl,
        mediaPublicUrl,
        mediaFormat,
        locationLat,
        locationLng,
        locationLabel,
        linkUrl,
        linkTitle,
        rawXmlHash,
        rawJson ? JSON.stringify(rawJson) : null,
        now,
      ],
    );
    return id;
  };

  const bindWechatToUser = async ({
    userId,
    appId,
    openid,
    unionid,
    nickname,
    avatarUrl,
    now = Date.now(),
  }) => {
    const existingOpenid = await findBindingByOpenid(appId, openid);
    if (existingOpenid && existingOpenid.user_id !== userId) {
      return { ok: false, message: '该微信已绑定其他账号，请先用该账号登录' };
    }
    const existingUserBind = await getWechatBindingForUser(userId, appId);
    if (existingUserBind) {
      return { ok: false, message: '你的账号已绑定其他微信，需先解绑后再试' };
    }

    try {
      await pool.query(
        `INSERT INTO h5_user_wechat_identities
         (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        [
          crypto.randomUUID(),
          userId,
          appId,
          openid,
          unionid,
          nickname,
          avatarUrl,
          now,
          now,
          now,
        ],
      );
      return { ok: true };
    } catch (err) {
      if (err?.code === 'ER_DUP_ENTRY') {
        return { ok: false, message: '微信绑定冲突，请重试' };
      }
      throw err;
    }
  };

  const touchWechatIdentity = async ({
    appId,
    openid,
    unionid,
    nickname,
    avatarUrl,
    now = Date.now(),
  }) => {
    await pool.query(
      `UPDATE h5_user_wechat_identities
       SET nickname = COALESCE(?, nickname),
           avatar_url = COALESCE(?, avatar_url),
           unionid = COALESCE(?, unionid),
           last_login_at = ?,
           updated_at = ?
       WHERE app_id = ? AND openid = ?`,
      [nickname, avatarUrl, unionid, now, now, appId, openid],
    );
  };

  const pruneWechatPendingBinds = async (now = Date.now()) => {
    await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]);
  };

  const createWechatPendingBind = async ({
    appId,
    openid,
    unionid,
    nickname,
    avatarUrl,
    returnTo = '/',
    utmSource = null,
    utmMedium = null,
    utmCampaign = null,
    now = Date.now(),
  }) => {
    await pruneWechatPendingBinds(now);
    const token = crypto.randomBytes(24).toString('base64url');
    await pool.query(
      `INSERT INTO h5_wechat_pending_binds
       (token, app_id, openid, unionid, nickname, avatar_url, return_to,
        utm_source, utm_medium, utm_campaign, expires_at, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        token,
        appId,
        openid,
        unionid,
        nickname,
        avatarUrl,
        returnTo,
        utmSource,
        utmMedium,
        utmCampaign,
        now + PENDING_BIND_TTL_MS,
        now,
      ],
    );
    return token;
  };

  const getWechatPendingBind = async (token, now = Date.now()) => {
    if (!token) return null;
    await pruneWechatPendingBinds(now);
    const [rows] = await pool.query(
      `SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to,
              utm_source, utm_medium, utm_campaign, expires_at
       FROM h5_wechat_pending_binds
       WHERE token = ?
       LIMIT 1`,
      [token],
    );
    const row = rows[0];
    if (!row || Number(row.expires_at) <= now) return null;
    return row;
  };

  const consumeWechatPendingBind = async (token) => {
    await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]);
  };

  const loginBoundWechatUser = async ({
    userId,
    appId,
    openid,
    unionid,
    nickname,
    avatarUrl,
    now = Date.now(),
  }) => {
    await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now });
    const user = await getUserById(userId);
    if (!user || user.status === 'disabled') {
      return { ok: false, message: '账户已禁用，请联系管理员' };
    }
    const token = await issueUserSession(user.id, user.role, now);
    return { ok: true, token, user: publicUser(user), isNewUser: false };
  };

  const generateWechatUsername = async (openid) => {
    const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
    const suffix = cleaned.slice(-8) || crypto.randomBytes(4).toString('hex');
    let candidate = `wx_${suffix}`.slice(0, 32);
    if (!isValidUsername(candidate)) {
      candidate = `wx_${crypto.randomBytes(4).toString('hex')}`;
    }
    for (let attempt = 0; attempt < 8; attempt += 1) {
      const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
        candidate,
      ]);
      if (!rows[0]) return candidate;
      candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto.randomBytes(2).toString('hex')}`.slice(
        0,
        32,
      );
    }
    return `wx_${crypto.randomBytes(6).toString('hex')}`.slice(0, 32);
  };

  const registerViaWechat = async ({
    appId,
    openid,
    unionid,
    nickname,
    avatarUrl,
    now = Date.now(),
  }) => {
    const normalized = await generateWechatUsername(openid);
    const randomPassword = crypto.randomBytes(24).toString('base64url');
    const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword);
    const userId = crypto.randomUUID();
    const layout = await publishLayoutFor({ id: userId, username: normalized });
    const workspaceRoot = layout.publishDir;
    const displayName = nickname?.trim() || '微信用户';

    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();
      await conn.query(
        `INSERT INTO h5_users
         (id, username, slug, email, display_name, salt, password_hash, password_algorithm,
          role, status, plan_type, workspace_root, signup_source, created_at, updated_at)
         VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', ?, ?)`,
        [
          userId,
          normalized,
          normalized,
          displayName,
          salt,
          passwordHash,
          passwordAlgorithm,
          workspaceRoot,
          now,
          now,
        ],
      );
      await conn.query(
        `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
         VALUES (?, ?, 0, ?)`,
        [userId, defaultSignupBalanceCents, now],
      );
      await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now);
      await conn.query(
        `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
        [userId, workspaceRoot],
      );
      await conn.query(
        `INSERT INTO h5_user_wechat_identities
         (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        [
          crypto.randomUUID(),
          userId,
          appId,
          openid,
          unionid,
          nickname,
          avatarUrl,
          now,
          now,
          now,
        ],
      );
      await initializeDefaultSpace(conn, userId, {
        quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
        now,
      });
      await conn.commit();
      ensureWorkspace(workspaceRoot);
      ensureUserMemoryProfile(workspaceRoot, {
        userId,
        displayName,
        username: normalized,
        slug: normalized,
      });
      if (subscriptionService) {
        subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {});
      }
      const user = await getUserById(userId);
      return { ok: true, user: publicUser(user) };
    } catch (err) {
      await conn.rollback();
      if (err?.code === 'ER_DUP_ENTRY') {
        return { ok: false, message: '微信账号注册冲突，请重试' };
      }
      throw err;
    } finally {
      conn.release();
    }
  };

  const resolveWechatAuth = async ({
    appId,
    openid,
    unionid,
    nickname,
    avatarUrl,
    intent = 'login',
    bindUserId = null,
    returnTo = '/',
    utmSource = null,
    utmMedium = null,
    utmCampaign = null,
    now = Date.now(),
  }) => {
    let binding = await findBindingByOpenid(appId, openid);

    if (!binding && unionid) {
      const unionBinding = await findBindingByUnionid(unionid);
      if (unionBinding) {
        const linked = await bindWechatToUser({
          userId: unionBinding.user_id,
          appId,
          openid,
          unionid,
          nickname,
          avatarUrl,
          now,
        });
        if (!linked.ok) return linked;
        binding = { user_id: unionBinding.user_id, status: unionBinding.status };
      }
    }

    if (binding) {
      if (binding.status === 'disabled') {
        return { ok: false, message: '账户已禁用，请联系管理员' };
      }
      return loginBoundWechatUser({
        userId: binding.user_id,
        appId,
        openid,
        unionid,
        nickname,
        avatarUrl,
        now,
      }).then((result) => (result.ok ? { ...result, action: 'login' } : result));
    }

    if (intent === 'bind' && bindUserId) {
      const user = await getUserById(bindUserId);
      if (!user) return { ok: false, message: '用户不存在' };
      if (user.status === 'disabled') {
        return { ok: false, message: '账户已禁用，请联系管理员' };
      }
      const bound = await bindWechatToUser({
        userId: bindUserId,
        appId,
        openid,
        unionid,
        nickname,
        avatarUrl,
        now,
      });
      if (!bound.ok) return bound;
      const token = await issueUserSession(user.id, user.role, now);
      return {
        ok: true,
        action: 'login',
        token,
        user: publicUser(user),
        isNewUser: false,
        bound: true,
      };
    }

    if (intent === 'register') {
      const registered = await registerViaWechat({
        appId,
        openid,
        unionid,
        nickname,
        avatarUrl,
        now,
      });
      if (!registered.ok) return registered;
      const token = await issueUserSession(registered.user.id, registered.user.role, now);
      return {
        ok: true,
        action: 'login',
        token,
        user: registered.user,
        isNewUser: true,
      };
    }

    const pendingToken = await createWechatPendingBind({
      appId,
      openid,
      unionid,
      nickname,
      avatarUrl,
      returnTo,
      utmSource,
      utmMedium,
      utmCampaign,
      now,
    });
    return {
      ok: true,
      action: 'binding_gate',
      pendingToken,
      wechatProfile: {
        nickname: nickname ?? null,
        avatarUrl: avatarUrl ?? null,
      },
      returnTo,
      utmSource,
      utmMedium,
      utmCampaign,
    };
  };

  const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => {
    const pending = await getWechatPendingBind(pendingToken, now);
    if (!pending) {
      return { ok: false, message: '绑定会话已过期，请重新微信登录' };
    }
    const registered = await registerViaWechat({
      appId: pending.app_id,
      openid: pending.openid,
      unionid: pending.unionid,
      nickname: pending.nickname,
      avatarUrl: pending.avatar_url,
      now,
    });
    if (!registered.ok) return registered;
    await consumeWechatPendingBind(pendingToken);
    const token = await issueUserSession(registered.user.id, registered.user.role, now);
    return {
      ok: true,
      token,
      user: registered.user,
      isNewUser: true,
      returnTo: pending.return_to || '/',
      utmSource: pending.utm_source,
      utmMedium: pending.utm_medium,
      utmCampaign: pending.utm_campaign,
    };
  };

  const completeWechatBindAccount = async ({
    pendingToken,
    username,
    password,
    ip = 'unknown',
    now = Date.now(),
  }) => {
    const pending = await getWechatPendingBind(pendingToken, now);
    if (!pending) {
      return { ok: false, message: '绑定会话已过期，请重新微信登录' };
    }
    const loginResult = await login({ username, password, ip, now });
    if (!loginResult.ok) return loginResult;

    const bound = await bindWechatToUser({
      userId: loginResult.user.id,
      appId: pending.app_id,
      openid: pending.openid,
      unionid: pending.unionid,
      nickname: pending.nickname,
      avatarUrl: pending.avatar_url,
      now,
    });
    if (!bound.ok) return bound;

    await consumeWechatPendingBind(pendingToken);
    return {
      ok: true,
      token: loginResult.token,
      user: loginResult.user,
      isNewUser: false,
      bound: true,
      returnTo: pending.return_to || '/',
    };
  };

  const getWechatBindingStatus = async (userId, appId) => {
    const row = await getWechatBindingForUser(userId, appId);
    if (!row) return { bound: false };
    return {
      bound: true,
      nickname: row.nickname,
      avatarUrl: row.avatar_url,
      lastLoginAt: Number(row.last_login_at),
      boundAt: Number(row.created_at),
    };
  };

  const loginByWechat = async (params) => {
    const result = await resolveWechatAuth({ ...params, intent: 'login' });
    if (!result.ok) return result;
    if (result.action === 'binding_gate') {
      return { ok: false, message: '需要完成账号绑定' };
    }
    return result;
  };

  return {
    USER_COOKIE,
    register,
    login,
    loginByWechat,
    resolveWechatAuth,
    completeWechatRegister,
    completeWechatBindAccount,
    getWechatPendingBind,
    getWechatBindingStatus,
    getWechatOpenidForUser,
    setRechargeNotifier(callback) {
      rechargeNotifier = typeof callback === 'function' ? callback : null;
    },
    findWechatUserByOpenid,
    getWechatAgentRoute,
    upsertWechatAgentRoute,
    clearWechatAgentRoute,
    recordWechatMpMessage,
    finishWechatMpMessage,
    insertWechatMpMessageDetail,
    resetPassword,
    verify,
    revoke,
    revokeAllSessionsForUser,
    getMe,
    listPathGrants,
    resolveWorkingDir,
    getUserPublishLayout,
    isPathAllowed,
    repairAllUserPublishDirs,
    registerAgentSession,
    getSessionNode,
    unregisterAgentSession,
    ownsSession,
    listOwnedSessionIds,
    canUseChat,
    getUserById,
    getUserPublic,
    listUsers,
    createUser,
    updateUser,
    purchaseSpaceQuota,
    recharge,
    billSessionUsage,
    listUsageRecords,
    listBillingLedger,
    getAdminSummary,
    ensureAdminUser,
    seedRoleCapabilityDefaults,
    resolveUserCapabilities,
    getAgentSessionPolicy,
    getRoleCapabilities,
    setRoleCapabilities,
    getUserCapabilities,
    setUserCapabilities,
    clearUserCapabilityOverrides,
    resolveUserPolicies,
    getRolePolicies,
    setRolePolicies,
    getUserPolicies,
    setUserPolicies,
    clearUserPolicyOverrides,
    getRoleSkills,
    setRoleSkills,
    getUserSkills,
    setUserSkills,
    clearUserSkillOverrides,
    syncUserSkillsForUser,
    capabilityCatalog: CAPABILITY_CATALOG,
    policyCatalog: POLICY_CATALOG,
    skillCatalog,
    publicUser,
    getUserById,
  };
}

function buildUserSessionCookie(token, secure, { domain, maxAge }) {
  const parts = [
    `${USER_COOKIE}=${encodeURIComponent(token)}`,
    'Path=/',
    'HttpOnly',
    'SameSite=Lax',
    `Max-Age=${maxAge}`,
  ];
  if (domain) parts.push(`Domain=${domain}`);
  if (secure) parts.push('Secure');
  return parts.join('; ');
}

export function userSessionCookie(token, secure, domain = resolveCookieDomain()) {
  return buildUserSessionCookie(token, secure, {
    domain,
    maxAge: 7 * 24 * 60 * 60,
  });
}

export function clearUserSessionCookie(secure, domain = resolveCookieDomain()) {
  return buildUserSessionCookie('', secure, { domain, maxAge: 0 });
}

/** Set shared-domain session and drop legacy host-only cookie from before H5_COOKIE_DOMAIN. */
export function userLoginCookies(token, secure, domain = resolveCookieDomain()) {
  const cookies = [userSessionCookie(token, secure, domain)];
  if (domain) {
    cookies.push(clearUserSessionCookie(secure, null));
  }
  return cookies;
}

/** Share login cookie across MindSpace + Plaza subdomains (e.g. .tkmind.cn). */
export function resolveCookieDomain() {
  const explicit = String(process.env.H5_COOKIE_DOMAIN ?? '').trim();
  if (explicit) return explicit;
  try {
    const base = String(process.env.H5_PUBLIC_BASE_URL ?? '').trim();
    if (!base) return null;
    const hostname = new URL(base).hostname.toLowerCase();
    if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
      return '.localhost';
    }
    if (hostname === 'tkmind.cn' || hostname.endsWith('.tkmind.cn')) {
      return '.tkmind.cn';
    }
  } catch {
    // ignore invalid base URL
  }
  return null;
}

/** 本地 localhost 开发时不设置 Domain，否则浏览器不会保存跨域 cookie。 */
export function resolveCookieDomainForRequest(req) {
  const explicit = String(process.env.H5_COOKIE_DOMAIN ?? '').trim();
  if (explicit) return explicit;

  const hostCandidates = [
    req?.get?.('x-forwarded-host'),
    req?.get?.('host'),
    req?.hostname,
  ];
  const origin = req?.get?.('origin');
  if (origin) {
    try {
      hostCandidates.push(new URL(origin).host);
    } catch {
      // ignore invalid origin
    }
  }

  for (const raw of hostCandidates) {
    const hostname = String(raw ?? '').split(':')[0].toLowerCase();
    if (hostname.endsWith('.localhost')) {
      return '.localhost';
    }
    if (
      hostname === 'localhost' ||
      hostname === '127.0.0.1' ||
      hostname === '::1' ||
      net.isIP(hostname)
    ) {
      return null;
    }
  }

  const hostname = String(req?.get?.('host') ?? req?.hostname ?? '')
    .split(':')[0]
    .toLowerCase();
  if (
    !hostname ||
    hostname === 'localhost' ||
    hostname === '127.0.0.1' ||
    hostname === '::1' ||
    net.isIP(hostname)
  ) {
    return null;
  }
  return resolveCookieDomain();
}
