import fs from 'node:fs';
import path from 'node:path';
import { closeDb, pool, queryOne } from './db.js';
import { loadEnv } from './env.js';

loadEnv();

type JsonRecord = Record<string, unknown> & { id?: string };
type CollectionMap = Record<string, JsonRecord[]>;
type Issue = {
  severity: 'info' | 'warning' | 'error' | 'critical';
  issueCode: string;
  message: string;
  fieldPath?: string;
  rawValueSample?: string;
};

const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const tenantSlug = process.env.TENANT_SLUG || 'master';
const tenantName = process.env.TENANT_NAME || '升本刷题通主租户';
const exportDir = path.resolve(process.cwd(), process.env.PB_EXPORT_DIR || '../../pb_export');
const sourceName = process.env.PB_SOURCE_NAME || path.basename(exportDir);
const importSecretValues = process.env.IMPORT_SECRET_VALUES === 'true';

const sensitiveKeyPattern =
  /(password|token|secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey|openid|unionid|wxaccesstoken|wechatsessionkey|smscode|verifycode|verificationcode|captcha)/i;
const identityKeyPattern = /(phone|email|openid|unionid|sessionkey)/i;
const externalAvatarKeyPattern = /(avatar|headimg|figureurl|profilephoto|portrait)/i;

const legacyLookupTables = new Set([
  'activation_codes',
  'badges',
  'categories',
  'code_batches',
  'coupons',
  'coupon_redemptions',
  'handbook_chapters',
  'handbook_entries',
  'handbook_subjects',
  'majors',
  'module_nodes',
  'orders',
  'practice_blueprints',
  'products',
  'questions',
  'region_modules',
  'regions',
  'reports',
  'schools',
  'scoreline_majors',
  'scoreline_schools',
  'subjects',
  'svip_plans',
  'video_explanations',
  'vocabulary_units',
  'vocabulary_words',
]);

const nonCollectionJsonFiles = new Set([
  'pb_schema.sqlite.json',
  'sqlite-export-manifest.json',
  'storage-manifest.json',
]);

const importBatchSize = Math.max(100, intFromEnv(process.env.PB_IMPORT_BATCH_SIZE, 1000));

function intFromEnv(value: unknown, fallback: number): number {
  if (value === null || value === undefined || value === '') return fallback;
  const parsed = Number(value);
  return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
}

function asArray(input: unknown): JsonRecord[] {
  if (Array.isArray(input)) return input as JsonRecord[];
  if (input && typeof input === 'object' && Array.isArray((input as { items?: unknown[] }).items)) {
    return (input as { items: JsonRecord[] }).items;
  }
  if (input && typeof input === 'object' && Array.isArray((input as { records?: unknown[] }).records)) {
    return (input as { records: JsonRecord[] }).records;
  }
  return [];
}

function collectionNameFromFile(fileName: string) {
  return fileName.replace(/\.json$/i, '');
}

function redactValue(value: unknown) {
  if (value === null || value === undefined || value === '') return value;
  return '[REDACTED]';
}

function sanitizeValue(value: unknown, keyPath = ''): unknown {
  const key = keyPath.split('.').pop() || keyPath;
  if (sensitiveKeyPattern.test(key)) return redactValue(value);
  if (Array.isArray(value)) return value.map((item, index) => sanitizeValue(item, `${keyPath}.${index}`));
  if (value && typeof value === 'object') {
    const copy: Record<string, unknown> = {};
    for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
      copy[childKey] = sanitizeValue(childValue, keyPath ? `${keyPath}.${childKey}` : childKey);
    }
    return copy;
  }
  return value;
}

function sanitizeRecord(record: JsonRecord): JsonRecord {
  return sanitizeValue(record) as JsonRecord;
}

function stripSensitiveKeys(value: unknown, keyPath = ''): unknown {
  const key = keyPath.split('.').pop() || keyPath;
  if (sensitiveKeyPattern.test(key) || externalAvatarKeyPattern.test(key)) return undefined;
  if (Array.isArray(value)) {
    return value.map((item, index) => stripSensitiveKeys(item, `${keyPath}.${index}`)).filter(item => item !== undefined);
  }
  if (value && typeof value === 'object') {
    const copy: Record<string, unknown> = {};
    for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
      const stripped = stripSensitiveKeys(childValue, keyPath ? `${keyPath}.${childKey}` : childKey);
      if (stripped !== undefined) copy[childKey] = stripped;
    }
    return copy;
  }
  return value;
}

function publicProfile(record: JsonRecord): JsonRecord {
  return stripSensitiveKeys(record) as JsonRecord;
}

function learningStats(value: unknown): JsonRecord {
  const stats = parseJsonish(value, {}) as JsonRecord;
  const { favorites: _favorites, wrongBook: _wrongBook, ...rest } = stats;
  return rest;
}

function text(value: unknown): string | null {
  if (value === null || value === undefined) return null;
  const result = String(value).trim();
  return result ? result : null;
}

function intValue(value: unknown, fallback = 0): number {
  if (value === null || value === undefined || value === '') return fallback;
  const parsed = Number(value);
  return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}

function numberValue(value: unknown, fallback: number | null = null): number | null {
  if (value === null || value === undefined || value === '') return fallback;
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : fallback;
}

function cents(value: unknown, fallback = 0): number {
  const parsed = numberValue(value, null);
  return parsed === null ? fallback : Math.round(parsed * 100);
}

function boolValue(value: unknown, fallback = false): boolean {
  if (value === null || value === undefined || value === '') return fallback;
  if (typeof value === 'boolean') return value;
  if (typeof value === 'number') return value !== 0;
  const normalized = String(value).trim().toLowerCase();
  if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
  if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false;
  return fallback;
}

function parseJsonish(value: unknown, fallback: unknown): unknown {
  if (value === null || value === undefined || value === '') return fallback;
  if (typeof value === 'string') {
    const trimmed = value.trim();
    if (!trimmed) return fallback;
    if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
      try {
        return JSON.parse(trimmed);
      } catch {
        return fallback;
      }
    }
  }
  return value;
}

function json(value: unknown, fallback: unknown) {
  return JSON.stringify(parseJsonish(value, fallback));
}

function arrayValue(value: unknown): unknown[] {
  const parsed = parseJsonish(value, []);
  if (Array.isArray(parsed)) return parsed;
  if (typeof parsed === 'string' && parsed.trim()) return parsed.split(',').map(item => item.trim()).filter(Boolean);
  return [];
}

function objectValue(value: unknown): JsonRecord {
  const parsed = parseJsonish(value, {});
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as JsonRecord;
  return {};
}

function entriesValue(value: unknown): Array<[string, unknown]> {
  const parsed = parseJsonish(value, {});
  if (Array.isArray(parsed)) return parsed.map((item, index) => [String(index), item]);
  if (parsed && typeof parsed === 'object') return Object.entries(parsed as Record<string, unknown>);
  return [];
}

function dateText(value: unknown): string {
  return text(value) || '';
}

function nullableDateText(...values: unknown[]): string {
  for (const value of values) {
    const result = dateText(value);
    if (result) return result;
  }
  return '';
}

function clampRate(value: unknown, fallback = 0.2): number {
  const parsed = numberValue(value, fallback);
  if (parsed === null || !Number.isFinite(parsed)) return fallback;
  return Math.min(1, Math.max(0, parsed));
}

function normalizeTenantRole(roleValue: unknown): string {
  const role = text(roleValue)?.toLowerCase();
  if (role === 'superadmin') return 'platform_admin';
  if (role === 'admin') return 'tenant_admin';
  if (role === 'operator') return 'tenant_operator';
  if (role === 'teacher') return 'teacher';
  if (role === 'sales') return 'sales';
  if (role === 'agent') return 'agent';
  return 'student';
}

function normalizeOrderStatus(value: unknown): string {
  const status = text(value)?.toLowerCase();
  if (status === 'paid' || status === 'success') return 'paid';
  if (status === 'failed' || status === 'fail') return 'failed';
  if (status === 'refunded' || status === 'refund') return 'refunded';
  if (status === 'closed' || status === 'cancelled' || status === 'canceled') return 'closed';
  return 'pending';
}

function normalizePaymentStatus(value: unknown): string {
  const status = normalizeOrderStatus(value);
  if (status === 'closed') return 'cancelled';
  return status;
}

function normalizeCouponType(value: unknown): string | null {
  const type = text(value)?.toLowerCase();
  if (!type) return null;
  if (['percent', 'percentage', 'rate'].includes(type)) return 'percent';
  return 'fixed';
}

function walkIssues(collection: string, value: unknown, pathParts: string[] = []): Issue[] {
  const issues: Issue[] = [];
  if (!value || typeof value !== 'object') return issues;

  for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
    const fieldPath = [...pathParts, key].join('.');
    if (sensitiveKeyPattern.test(key) && childValue !== null && childValue !== undefined && childValue !== '') {
      issues.push({
        severity: collection === 'settings' || collection === 'crm_config' ? 'critical' : 'warning',
        issueCode: 'sensitive_field_in_source',
        message: `Sensitive source field "${fieldPath}" must not be copied into public business tables.`,
        fieldPath,
        rawValueSample: String(childValue).slice(0, 6) + '...',
      });
    }
    if (childValue && typeof childValue === 'object') issues.push(...walkIssues(collection, childValue, [...pathParts, key]));
  }

  return issues;
}

function detectIssues(collection: string, record: JsonRecord): Issue[] {
  const issues = walkIssues(collection, record);

  if (collection === 'settings') {
    issues.push({
      severity: 'warning',
      issueCode: 'settings_monolith',
      message: 'Legacy settings is a monolithic config table. Values must be split into public config, private secrets, payment accounts and storage config.',
    });
  }

  if (collection === 'users') {
    if (record.stats && typeof record.stats === 'object') {
      issues.push({
        severity: 'info',
        issueCode: 'json_stats_requires_normalization',
        message: 'users.stats is legacy JSON and should be normalized into learning/favorite/wrong-book tables.',
        fieldPath: 'stats',
      });
    }
    if (record.isSvip !== undefined || record.svipRegions !== undefined || record.svipExpiry !== undefined) {
      issues.push({
        severity: 'warning',
        issueCode: 'legacy_membership_state',
        message: 'Legacy SVIP fields should be converted into entitlements instead of copied as mutable user flags.',
      });
    }
  }

  if (['smscodes', 'customer_messages'].includes(collection)) {
    issues.push({
      severity: 'warning',
      issueCode: 'privacy_sensitive_collection',
      message: `${collection} contains personal or verification data and is intentionally not imported into public business tables by default.`,
    });
  }

  return issues;
}

async function ensureTenant() {
  await pool.query(
    `
    insert into public.tenants (id, slug, name, status, mode)
    values ($1, $2, $3, 'active', 'platform_owned')
    on conflict (id) do update set
      slug = excluded.slug,
      name = excluded.name,
      updated_at = now()
    `,
    [tenantId, tenantSlug, tenantName],
  );

  await pool.query(
    `
    insert into public.tenant_branding (tenant_id, brand_name, short_name)
    values ($1, $2, $3)
    on conflict (tenant_id) do nothing
    `,
    [tenantId, tenantName, tenantName],
  );

  await pool.query(
    `
    insert into public.tenant_settings (tenant_id)
    values ($1)
    on conflict (tenant_id) do nothing
    `,
    [tenantId],
  );
}

async function createRun() {
  const row = await queryOne<{ id: string }>(
    `
    insert into public.pb_import_runs (tenant_id, source_name, source_kind)
    values ($1, $2, 'json')
    returning id
    `,
    [tenantId, sourceName],
  );
  if (!row) throw new Error('Failed to create import run');
  return row.id;
}

async function importRaw(runId: string, collection: string, records: JsonRecord[]) {
  let count = 0;
  const issueRows: Array<{
    legacyId: string;
    issue: Issue;
  }> = [];

  for (let offset = 0; offset < records.length; offset += importBatchSize) {
    const batch = records.slice(offset, offset + importBatchSize);
    const legacyIds: string[] = [];
    const recordPayloads: string[] = [];

    for (const record of batch) {
      const legacyId = text(record.id);
      if (!legacyId) continue;
      legacyIds.push(legacyId);
      recordPayloads.push(JSON.stringify(sanitizeRecord(record)));
      for (const detectedIssue of detectIssues(collection, record)) {
        issueRows.push({ legacyId, issue: detectedIssue });
      }
    }

    if (!legacyIds.length) continue;
    await pool.query(
      `
      insert into public.pb_raw_records (run_id, tenant_id, collection_name, legacy_id, record)
      select $1::uuid, $2::uuid, $3::text, raw.legacy_id, raw.record_payload::jsonb
      from unnest($4::text[], $5::text[]) as raw(legacy_id, record_payload)
      on conflict (run_id, collection_name, legacy_id)
      do update set record = excluded.record, imported_at = now()
      `,
      [runId, tenantId, collection, legacyIds, recordPayloads],
    );
    count += legacyIds.length;
  }

  await insertImportIssueRows(runId, collection, issueRows);
  return count;
}

async function insertImportIssueRows(
  runId: string,
  collection: string,
  rows: Array<{ legacyId: string | null; issue: Issue }>,
) {
  for (let offset = 0; offset < rows.length; offset += importBatchSize) {
    const batch = rows.slice(offset, offset + importBatchSize);
    if (!batch.length) continue;
    await pool.query(
      `
      insert into public.pb_import_issues (
        run_id, tenant_id, collection_name, legacy_id, severity,
        issue_code, message, field_path, raw_value_sample
      )
      select $1::uuid, $2::uuid, $3::text, issue.legacy_id, issue.severity,
             issue.issue_code, issue.message, issue.field_path, issue.raw_value_sample
      from unnest(
        $4::text[],
        $5::text[],
        $6::text[],
        $7::text[],
        $8::text[],
        $9::text[]
      ) as issue(legacy_id, severity, issue_code, message, field_path, raw_value_sample)
      `,
      [
        runId,
        tenantId,
        collection,
        batch.map(row => row.legacyId),
        batch.map(row => row.issue.severity),
        batch.map(row => row.issue.issueCode),
        batch.map(row => row.issue.message),
        batch.map(row => row.issue.fieldPath || null),
        batch.map(row => row.issue.rawValueSample || null),
      ],
    );
  }
}

async function issue(runId: string, collection: string, legacyId: unknown, issueCode: string, message: string, severity: Issue['severity'] = 'warning') {
  await pool.query(
    `
    insert into public.pb_import_issues (
      run_id, tenant_id, collection_name, legacy_id, severity, issue_code, message
    )
    values ($1,$2,$3,$4,$5,$6,$7)
    `,
    [runId, tenantId, collection, text(legacyId), severity, issueCode, message],
  );
}

async function markNormalized(runId: string, collection: string) {
  await pool.query(
    `
    update public.pb_raw_records
    set normalized = true
    where run_id = $1 and collection_name = $2
    `,
    [runId, collection],
  );
}

const legacyLookupCache = new Map<string, string | null>();
const userLookupCache = new Map<string, string | null>();
const questionVersionLookupCache = new Map<string, string | null>();

async function legacyId(tableName: string, legacyValue: unknown): Promise<string | null> {
  const value = text(legacyValue);
  if (!value) return null;
  if (!legacyLookupTables.has(tableName)) throw new Error(`Unsafe legacy lookup table: ${tableName}`);
  const cacheKey = `${tableName}:${value}`;
  if (legacyLookupCache.has(cacheKey)) return legacyLookupCache.get(cacheKey) || null;
  const row = await queryOne<{ id: string }>(
    `select id from public.${tableName} where tenant_id = $1 and legacy_id = $2 limit 1`,
    [tenantId, value],
  );
  const result = row?.id || null;
  legacyLookupCache.set(cacheKey, result);
  return result;
}

async function userIdByLegacy(value: unknown): Promise<string | null> {
  const legacyValue = text(value);
  if (!legacyValue) return null;
  if (userLookupCache.has(legacyValue)) return userLookupCache.get(legacyValue) || null;
  const row = await queryOne<{ id: string }>('select id from public.platform_users where legacy_id = $1 limit 1', [legacyValue]);
  const result = row?.id || null;
  userLookupCache.set(legacyValue, result);
  return result;
}

async function currentQuestionVersionId(questionId: string | null): Promise<string | null> {
  if (!questionId) return null;
  if (questionVersionLookupCache.has(questionId)) return questionVersionLookupCache.get(questionId) || null;
  const row = await queryOne<{ current_version_id: string | null }>(
    `select current_version_id from public.questions where tenant_id = $1 and id = $2 limit 1`,
    [tenantId, questionId],
  );
  const result = row?.current_version_id || null;
  questionVersionLookupCache.set(questionId, result);
  return result;
}

async function bulkUserIdsByLegacy(values: unknown[]): Promise<Map<string, string>> {
  const legacyValues = [...new Set(values.map(value => text(value)).filter((value): value is string => Boolean(value)))];
  const result = new Map<string, string>();
  const missing: string[] = [];

  for (const legacyValue of legacyValues) {
    if (userLookupCache.has(legacyValue)) {
      const cached = userLookupCache.get(legacyValue);
      if (cached) result.set(legacyValue, cached);
    } else {
      missing.push(legacyValue);
    }
  }

  for (let offset = 0; offset < missing.length; offset += importBatchSize) {
    const batch = missing.slice(offset, offset + importBatchSize);
    if (!batch.length) continue;
    const rows = await pool.query<{ legacy_id: string; id: string }>(
      `
      select legacy_id, id
      from public.platform_users
      where legacy_id = any($1::text[])
      `,
      [batch],
    );
    const found = new Map(rows.rows.map(row => [row.legacy_id, row.id]));
    for (const legacyValue of batch) {
      const id = found.get(legacyValue) || null;
      userLookupCache.set(legacyValue, id);
      if (id) result.set(legacyValue, id);
    }
  }

  return result;
}

async function bulkLegacyIds(tableName: string, values: unknown[]): Promise<Map<string, string>> {
  const legacyValues = [...new Set(values.map(value => text(value)).filter((value): value is string => Boolean(value)))];
  const result = new Map<string, string>();
  const missing: string[] = [];

  if (!legacyLookupTables.has(tableName)) throw new Error(`Unsafe legacy lookup table: ${tableName}`);

  for (const legacyValue of legacyValues) {
    const cacheKey = `${tableName}:${legacyValue}`;
    if (legacyLookupCache.has(cacheKey)) {
      const cached = legacyLookupCache.get(cacheKey);
      if (cached) result.set(legacyValue, cached);
    } else {
      missing.push(legacyValue);
    }
  }

  for (let offset = 0; offset < missing.length; offset += importBatchSize) {
    const batch = missing.slice(offset, offset + importBatchSize);
    if (!batch.length) continue;
    const rows = await pool.query<{ legacy_id: string; id: string }>(
      `
      select legacy_id, id
      from public.${tableName}
      where tenant_id = $1 and legacy_id = any($2::text[])
      `,
      [tenantId, batch],
    );
    const found = new Map(rows.rows.map(row => [row.legacy_id, row.id]));
    for (const legacyValue of batch) {
      const id = found.get(legacyValue) || null;
      legacyLookupCache.set(`${tableName}:${legacyValue}`, id);
      if (id) result.set(legacyValue, id);
    }
  }

  return result;
}

async function bulkQuestionVersionIds(questionIds: Array<string | null>): Promise<Map<string, string>> {
  const values = [...new Set(questionIds.filter((value): value is string => Boolean(value)))];
  const result = new Map<string, string>();
  const missing: string[] = [];

  for (const questionId of values) {
    if (questionVersionLookupCache.has(questionId)) {
      const cached = questionVersionLookupCache.get(questionId);
      if (cached) result.set(questionId, cached);
    } else {
      missing.push(questionId);
    }
  }

  for (let offset = 0; offset < missing.length; offset += importBatchSize) {
    const batch = missing.slice(offset, offset + importBatchSize);
    if (!batch.length) continue;
    const rows = await pool.query<{ id: string; current_version_id: string | null }>(
      `
      select id, current_version_id
      from public.questions
      where tenant_id = $1 and id = any($2::uuid[])
      `,
      [tenantId, batch],
    );
    const found = new Map(rows.rows.map(row => [row.id, row.current_version_id]));
    for (const questionId of batch) {
      const versionId = found.get(questionId) || null;
      questionVersionLookupCache.set(questionId, versionId);
      if (versionId) result.set(questionId, versionId);
    }
  }

  return result;
}

async function upsertSecret(secretScope: string, secretKey: string, secretValue: unknown, provider: string | null = null) {
  if (!importSecretValues || secretValue === null || secretValue === undefined || secretValue === '') return;
  await pool.query(
    `
    insert into app_private.tenant_secrets (tenant_id, secret_scope, secret_key, secret_value, provider)
    values ($1,$2,$3,$4,$5)
    on conflict (tenant_id, secret_scope, secret_key)
    do update set secret_value = excluded.secret_value, provider = excluded.provider, updated_at = now()
    `,
    [tenantId, secretScope, secretKey, String(secretValue), provider],
  );
}

async function normalizeRegions(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.regions (
        tenant_id, legacy_id, name, code, short_name, full_name, icon, pinyin,
        sort_order, is_hot, is_active, config, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
        coalesce(nullif($13::text,'')::timestamptz, now()),
        coalesce(nullif($14::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        name = excluded.name,
        code = excluded.code,
        short_name = excluded.short_name,
        full_name = excluded.full_name,
        icon = excluded.icon,
        pinyin = excluded.pinyin,
        sort_order = excluded.sort_order,
        is_hot = excluded.is_hot,
        is_active = excluded.is_active,
        config = excluded.config,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        text(r.name) || '未命名地区',
        text(r.code),
        text(r.shortName),
        text(r.fullName),
        text(r.icon),
        text(r.pinyin),
        intValue(r.order),
        boolValue(r.isHot),
        boolValue(r.isActive, true),
        json(r.config, {}),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeRegionModules(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.region_modules (
        tenant_id, region_id, legacy_id, name, type, icon, color, text_color,
        description, route, sort_order, is_primary_school_module, is_active,
        created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,
        coalesce(nullif($14::text,'')::timestamptz, now()),
        coalesce(nullif($15::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        name = excluded.name,
        type = excluded.type,
        icon = excluded.icon,
        color = excluded.color,
        text_color = excluded.text_color,
        description = excluded.description,
        route = excluded.route,
        sort_order = excluded.sort_order,
        is_primary_school_module = excluded.is_primary_school_module,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        r.id,
        text(r.name) || '未命名模块',
        text(r.type),
        text(r.icon),
        text(r.color),
        text(r.textColor),
        text(r.description),
        text(r.route),
        intValue(r.order),
        boolValue(r.isPrimarySchoolModule),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeModuleNodes(records: JsonRecord[]) {
  for (const r of records) {
    const legacyParent = text(r.parentId);
    await pool.query(
      `
      insert into public.module_nodes (
        tenant_id, region_id, module_id, parent_id, legacy_id, legacy_parent_id,
        legacy_module_id, type, name, sort_order, is_active, metadata,
        created_at, updated_at
      )
      values ($1,$2,$3,null,$4,$5,$6,$7,$8,$9,$10,$11,
        coalesce(nullif($12::text,'')::timestamptz, now()),
        coalesce(nullif($13::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        module_id = excluded.module_id,
        legacy_parent_id = excluded.legacy_parent_id,
        legacy_module_id = excluded.legacy_module_id,
        type = excluded.type,
        name = excluded.name,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        metadata = excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        await legacyId('region_modules', r.moduleId),
        r.id,
        legacyParent,
        text(r.moduleId),
        ['category', 'subject', 'chapter', 'paper', 'school', 'major', 'custom'].includes(text(r.type) || '')
          ? text(r.type)
          : 'custom',
        text(r.name) || '未命名节点',
        intValue(r.order),
        boolValue(r.isActive, true),
        json(r.metadata, {}),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }

  await pool.query(
    `
    update public.module_nodes child
    set parent_id = parent.id
    from public.module_nodes parent
    where child.tenant_id = $1
      and parent.tenant_id = child.tenant_id
      and child.legacy_parent_id is not null
      and parent.legacy_id = child.legacy_parent_id
    `,
    [tenantId],
  );
}

async function normalizeSchools(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.schools (
        tenant_id, region_id, module_id, legacy_id, name, professional_exam_date,
        metadata, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,
        coalesce(nullif($8::text,'')::timestamptz, now()),
        coalesce(nullif($9::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        module_id = excluded.module_id,
        name = excluded.name,
        professional_exam_date = excluded.professional_exam_date,
        metadata = excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        await legacyId('region_modules', r.moduleId),
        r.id,
        text(r.name) || '未命名院校',
        text(r.professionalExamDate),
        json({ isActive: boolValue(r.isActive, true) }, {}),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeMajors(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.majors (
        tenant_id, region_id, school_id, legacy_id, name, description,
        study_tips, sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,
        coalesce(nullif($10::text,'')::timestamptz, now()),
        coalesce(nullif($11::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        school_id = excluded.school_id,
        name = excluded.name,
        description = excluded.description,
        study_tips = excluded.study_tips,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        await legacyId('schools', r.schoolId),
        r.id,
        text(r.name) || '未命名专业',
        text(r.description),
        text(r.studyTips),
        intValue(r.order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeSubjects(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.subjects (
        tenant_id, region_id, module_id, school_id, major_id, node_id,
        legacy_id, name, type, major_legacy_ids, icon, description,
        stats, sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,null,$6,$7,$8,$9,$10,$11,$12,$13,true,
        coalesce(nullif($14::text,'')::timestamptz, now()),
        coalesce(nullif($15::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        module_id = excluded.module_id,
        school_id = excluded.school_id,
        major_id = excluded.major_id,
        name = excluded.name,
        type = excluded.type,
        major_legacy_ids = excluded.major_legacy_ids,
        icon = excluded.icon,
        description = excluded.description,
        stats = excluded.stats,
        sort_order = excluded.sort_order,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        await legacyId('region_modules', r.moduleId),
        await legacyId('schools', r.schoolId),
        await legacyId('majors', r.majorId),
        r.id,
        text(r.name) || '未命名科目',
        text(r.type) === 'professional' ? 'professional' : text(r.type) === 'cultural' ? 'cultural' : null,
        json(arrayValue(r.majorIds), []),
        text(r.icon),
        text(r.description),
        json(r.stats, {}),
        intValue(r.order),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeCategories(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.categories (
        tenant_id, subject_id, node_id, legacy_id, name, category_type,
        sort_order, svip_question_limit, is_active, created_at, updated_at
      )
      values ($1,$2,null,$3,$4,$5,$6,$7,true,
        coalesce(nullif($8::text,'')::timestamptz, now()),
        coalesce(nullif($9::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        subject_id = excluded.subject_id,
        name = excluded.name,
        category_type = excluded.category_type,
        sort_order = excluded.sort_order,
        svip_question_limit = excluded.svip_question_limit,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('subjects', r.subjectId),
        r.id,
        text(r.name) || '未命名章节',
        text(r.categoryType) === 'paper' ? 'paper' : 'chapter',
        intValue(r.order),
        numberValue(r.svipQuestionLimit, null),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeQuestions(records: JsonRecord[]) {
  for (const r of records) {
    const inserted = await queryOne<{ id: string }>(
      `
      insert into public.questions (
        tenant_id, subject_id, category_id, node_id, legacy_id, legacy_subject_id,
        legacy_category_id, legacy_node_id, type, type_label, difficulty, tags,
        media_url, status, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'published',
        coalesce(nullif($14::text,'')::timestamptz, now()),
        coalesce(nullif($15::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        subject_id = excluded.subject_id,
        category_id = excluded.category_id,
        node_id = excluded.node_id,
        legacy_subject_id = excluded.legacy_subject_id,
        legacy_category_id = excluded.legacy_category_id,
        legacy_node_id = excluded.legacy_node_id,
        type = excluded.type,
        type_label = excluded.type_label,
        difficulty = excluded.difficulty,
        tags = excluded.tags,
        media_url = excluded.media_url,
        updated_at = excluded.updated_at
      returning id
      `,
      [
        tenantId,
        await legacyId('subjects', r.subjectId),
        await legacyId('categories', r.categoryId),
        await legacyId('module_nodes', r.nodeId),
        r.id,
        text(r.subjectId),
        text(r.categoryId),
        text(r.nodeId),
        text(r.type) || 'choice',
        text(r.typeLabel),
        numberValue(r.difficulty, null),
        json(r.tags, []),
        text(r.media),
        dateText(r.created),
        dateText(r.updated),
      ],
    );

    if (!inserted) continue;

    const version = await queryOne<{ id: string }>(
      `
      insert into public.question_versions (
        tenant_id, question_id, version_no, content, options, correct_option_index,
        correct_option_indices, answer_text, explanation, sub_questions, code_lang, code_template
      )
      values ($1,$2,1,$3,$4,$5,$6,$7,$8,$9,$10,$11)
      on conflict (question_id, version_no) do update set
        content = excluded.content,
        options = excluded.options,
        correct_option_index = excluded.correct_option_index,
        correct_option_indices = excluded.correct_option_indices,
        answer_text = excluded.answer_text,
        explanation = excluded.explanation,
        sub_questions = excluded.sub_questions,
        code_lang = excluded.code_lang,
        code_template = excluded.code_template
      returning id
      `,
      [
        tenantId,
        inserted.id,
        text(r.content),
        json(r.options, []),
        numberValue(r.correctOptionIndex, null),
        json(r.correctOptionIndices, []),
        text(r.answerText),
        text(r.explanation),
        json(r.sub_questions || r.subQuestions, []),
        text(r.code_lang),
        text(r.code_template),
      ],
    );

    if (version) {
      await pool.query('update public.questions set current_version_id = $1 where id = $2', [version.id, inserted.id]);
    }
  }
}

async function buildPracticeNavigation(runId: string) {
  const client = await pool.connect();
  try {
    await client.query('begin');

    await client.query(
      `
      insert into public.content_entries (
        tenant_id, region_id, legacy_id, entry_key, name, entry_type,
        icon, route, description, visibility, access_rules, layout_config,
        sort_order, is_active
      )
      select rm.tenant_id, rm.region_id, 'region_module:' || rm.legacy_id,
             'legacy-question-practice:' || rm.legacy_id,
             rm.name, 'question_practice',
             rm.icon, coalesce(rm.route, '/subjects'), rm.description,
             'public', '{}'::jsonb,
             jsonb_build_object(
               'source', 'pocketbase.region_modules',
               'legacyModuleId', rm.legacy_id,
               'legacyType', rm.type,
               'route', rm.route
             ),
             rm.sort_order, rm.is_active
      from public.region_modules rm
      where rm.tenant_id = $1
        and rm.legacy_id is not null
        and (
          rm.type = 'subjects'
          or coalesce(rm.route, '') like '/subjects%'
          or exists (
            select 1 from public.subjects s
            where s.tenant_id = rm.tenant_id and s.module_id = rm.id
          )
        )
      on conflict (tenant_id, entry_key)
      do update set region_id = excluded.region_id,
                    legacy_id = excluded.legacy_id,
                    name = excluded.name,
                    entry_type = excluded.entry_type,
                    icon = excluded.icon,
                    route = excluded.route,
                    description = excluded.description,
                    visibility = excluded.visibility,
                    access_rules = excluded.access_rules,
                    layout_config = excluded.layout_config,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.content_entries (
        tenant_id, region_id, legacy_id, entry_key, name, entry_type,
        icon, route, description, visibility, access_rules, layout_config,
        sort_order, is_active
      )
      select $1::uuid, null, 'migration_review:orphan_questions',
             'migration-review:orphan-questions',
             '迁移待复核题库', 'question_practice',
             null, '/subjects', '旧题库中科目或节点归属断裂的题目，需由租户后台重新归类后再发布。',
             'hidden',
             jsonb_build_object('requiresReview', true),
             jsonb_build_object('source', 'pocketbase.migration_review.orphan_questions'),
             999999, true
      where exists (
        select 1
        from public.questions q
        join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
        where q.tenant_id = $1
          and q.legacy_id is not null
          and q.status = 'published'
          and q.node_id is null
          and q.category_id is null
          and q.legacy_category_id is null
          and q.legacy_node_id is not null
          and not exists (
            select 1
            from public.content_nodes subject_node
            where subject_node.tenant_id = s.tenant_id
              and subject_node.legacy_id = 'subject:' || s.legacy_id
          )
      )
      on conflict (tenant_id, entry_key)
      do update set legacy_id = excluded.legacy_id,
                    name = excluded.name,
                    entry_type = excluded.entry_type,
                    route = excluded.route,
                    description = excluded.description,
                    visibility = excluded.visibility,
                    access_rules = excluded.access_rules,
                    layout_config = excluded.layout_config,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.content_nodes (
        tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
        name, node_type, marker_type, marker_config, path, depth,
        sort_order, is_active, is_selectable, is_leaf, access_rules, metadata
      )
      select mn.tenant_id, ce.id, mn.region_id, null,
             'module_node:' || mn.legacy_id, 'module_node:' || mn.legacy_id,
             mn.name,
             case
               when mn.type in ('category','subject','chapter','paper','school','major','custom') then mn.type
               else 'custom'
             end,
             case
               when mn.type = 'school' then 'school'
               when mn.type = 'major' then 'major'
               when mn.type = 'subject' then 'subject'
               else null
             end,
             jsonb_build_object(
               'source', 'pocketbase.module_nodes',
               'legacyModuleNodeId', mn.legacy_id,
               'legacyModuleId', rm.legacy_id,
               'legacyType', mn.type
             ),
             null, 0, mn.sort_order, mn.is_active, true, true, '{}'::jsonb,
             jsonb_build_object('source', 'pocketbase.module_nodes')
      from public.module_nodes mn
      join public.region_modules rm on rm.id = mn.module_id and rm.tenant_id = mn.tenant_id
      join public.content_entries ce
        on ce.tenant_id = rm.tenant_id
       and ce.legacy_id = 'region_module:' || rm.legacy_id
      where mn.tenant_id = $1
        and mn.legacy_id is not null
      on conflict (tenant_id, legacy_id)
      do update set entry_id = excluded.entry_id,
                    region_id = excluded.region_id,
                    name = excluded.name,
                    node_type = excluded.node_type,
                    marker_type = excluded.marker_type,
                    marker_config = excluded.marker_config,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    is_selectable = excluded.is_selectable,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.content_nodes child
      set parent_id = parent.id,
          updated_at = now()
      from public.module_nodes mn
      join public.content_nodes parent
        on parent.tenant_id = mn.tenant_id
       and parent.legacy_id = 'module_node:' || mn.legacy_parent_id
      where child.tenant_id = $1
        and mn.tenant_id = child.tenant_id
        and child.legacy_id = 'module_node:' || mn.legacy_id
        and mn.legacy_parent_id is not null
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.content_nodes (
        tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
        name, node_type, marker_type, marker_config, path, depth,
        sort_order, is_active, is_selectable, is_leaf, access_rules, metadata
      )
      select s.tenant_id, ce.id, s.region_id, parent_node.id,
             'subject:' || s.legacy_id, 'subject:' || s.legacy_id,
             s.name, 'subject', 'subject',
             jsonb_build_object(
               'source', 'pocketbase.subjects',
               'subjectId', s.id,
               'legacySubjectId', s.legacy_id,
               'subjectType', s.type
             ),
             null, 0, s.sort_order, s.is_active, true, false, '{}'::jsonb,
             jsonb_build_object('source', 'pocketbase.subjects')
      from public.subjects s
      join public.region_modules rm on rm.id = s.module_id and rm.tenant_id = s.tenant_id
      join public.content_entries ce
        on ce.tenant_id = rm.tenant_id
       and ce.legacy_id = 'region_module:' || rm.legacy_id
      left join public.module_nodes mn on mn.id = s.node_id and mn.tenant_id = s.tenant_id
      left join public.content_nodes parent_node
        on parent_node.tenant_id = s.tenant_id
       and parent_node.legacy_id = 'module_node:' || mn.legacy_id
      where s.tenant_id = $1
        and s.legacy_id is not null
      on conflict (tenant_id, legacy_id)
      do update set entry_id = excluded.entry_id,
                    region_id = excluded.region_id,
                    parent_id = excluded.parent_id,
                    name = excluded.name,
                    node_type = excluded.node_type,
                    marker_type = excluded.marker_type,
                    marker_config = excluded.marker_config,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    is_selectable = excluded.is_selectable,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.content_nodes (
        tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
        name, node_type, marker_type, marker_config, path, depth,
        sort_order, is_active, is_selectable, is_leaf, access_rules, metadata
      )
      select c.tenant_id, subject_node.entry_id, s.region_id, subject_node.id,
             'category:' || c.legacy_id, 'category:' || c.legacy_id,
             c.name,
             case when c.category_type = 'paper' then 'paper' else 'chapter' end,
             null,
             jsonb_build_object(
               'source', 'pocketbase.categories',
               'categoryId', c.id,
               'legacyCategoryId', c.legacy_id,
               'categoryType', c.category_type,
               'subjectId', s.id,
               'legacySubjectId', s.legacy_id
             ),
             null, 0, c.sort_order, c.is_active, true, true, '{}'::jsonb,
             jsonb_build_object('source', 'pocketbase.categories')
      from public.categories c
      join public.subjects s on s.id = c.subject_id and s.tenant_id = c.tenant_id
      join public.content_nodes subject_node
        on subject_node.tenant_id = s.tenant_id
       and subject_node.legacy_id = 'subject:' || s.legacy_id
      where c.tenant_id = $1
        and c.legacy_id is not null
      on conflict (tenant_id, legacy_id)
      do update set entry_id = excluded.entry_id,
                    region_id = excluded.region_id,
                    parent_id = excluded.parent_id,
                    name = excluded.name,
                    node_type = excluded.node_type,
                    marker_type = excluded.marker_type,
                    marker_config = excluded.marker_config,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    is_selectable = excluded.is_selectable,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.content_nodes (
        tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
        name, node_type, marker_type, marker_config, path, depth,
        sort_order, is_active, is_selectable, is_leaf, access_rules, metadata
      )
      select q.tenant_id, subject_node.entry_id, s.region_id, subject_node.id,
             'migration_review:unresolved_category:' || s.legacy_id,
             'migration_review:unresolved_category:' || s.legacy_id,
             '迁移待复核题目', 'custom', 'custom',
             jsonb_build_object(
               'source', 'pocketbase.migration_review.unresolved_category',
               'subjectId', s.id,
               'legacySubjectId', s.legacy_id,
               'questionCount', count(*),
               'reason', 'legacy_category_or_node_missing'
             ),
             null, 0, 999999, false, false, true,
             jsonb_build_object('requiresReview', true),
             jsonb_build_object(
               'source', 'pocketbase.migration_review.unresolved_category',
               'reviewRequired', true,
               'questionCount', count(*)
             )
      from public.questions q
      join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
      join public.content_nodes subject_node
        on subject_node.tenant_id = s.tenant_id
       and subject_node.legacy_id = 'subject:' || s.legacy_id
      where q.tenant_id = $1
        and q.category_id is null
        and q.legacy_category_id is not null
      group by q.tenant_id, subject_node.entry_id, s.region_id, subject_node.id, s.id, s.legacy_id
      on conflict (tenant_id, legacy_id)
      do update set entry_id = excluded.entry_id,
                    region_id = excluded.region_id,
                    parent_id = excluded.parent_id,
                    marker_config = excluded.marker_config,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.content_nodes (
        tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
        name, node_type, marker_type, marker_config, path, depth,
        sort_order, is_active, is_selectable, is_leaf, access_rules, metadata
      )
      select q.tenant_id, review_entry.id, s.region_id, null,
             'migration_review:orphan_subject:' || s.legacy_id,
             'migration_review:orphan_subject:' || s.legacy_id,
             s.name || ' · 迁移待复核题目',
             'subject', 'subject',
             jsonb_build_object(
               'source', 'pocketbase.migration_review.orphan_subject',
               'subjectId', s.id,
               'legacySubjectId', s.legacy_id,
               'questionCount', count(*),
               'reason', 'legacy_node_or_subject_module_missing'
             ),
             null, 0, s.sort_order, false, false, true,
             jsonb_build_object('requiresReview', true),
             jsonb_build_object(
               'source', 'pocketbase.migration_review.orphan_subject',
               'reviewRequired', true,
               'questionCount', count(*)
             )
      from public.questions q
      join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
      join public.content_entries review_entry
        on review_entry.tenant_id = q.tenant_id
       and review_entry.legacy_id = 'migration_review:orphan_questions'
      where q.tenant_id = $1
        and q.legacy_id is not null
        and q.status = 'published'
        and q.node_id is null
        and q.category_id is null
        and q.legacy_category_id is null
        and q.legacy_node_id is not null
        and s.legacy_id is not null
        and not exists (
          select 1
          from public.content_nodes subject_node
          where subject_node.tenant_id = s.tenant_id
            and subject_node.legacy_id = 'subject:' || s.legacy_id
        )
      group by q.tenant_id, review_entry.id, s.region_id, s.id, s.legacy_id, s.name, s.sort_order
      on conflict (tenant_id, legacy_id)
      do update set entry_id = excluded.entry_id,
                    region_id = excluded.region_id,
                    name = excluded.name,
                    node_type = excluded.node_type,
                    marker_type = excluded.marker_type,
                    marker_config = excluded.marker_config,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    is_selectable = excluded.is_selectable,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      with recursive tree as (
        select id, parent_id,
               ('n_' || replace(id::text, '-', ''))::ltree as path,
               0 as depth
        from public.content_nodes
        where tenant_id = $1 and parent_id is null

        union all

        select child.id, child.parent_id,
               (tree.path::text || '.n_' || replace(child.id::text, '-', ''))::ltree as path,
               tree.depth + 1 as depth
        from public.content_nodes child
        join tree on tree.id = child.parent_id
        where child.tenant_id = $1
      )
      update public.content_nodes node
      set path = tree.path,
          depth = tree.depth,
          updated_at = now()
      from tree
      where node.id = tree.id
        and node.tenant_id = $1
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.content_nodes node
      set is_leaf = not exists (
            select 1
            from public.content_nodes child
            where child.tenant_id = node.tenant_id
              and child.parent_id = node.id
          ),
          updated_at = now()
      where node.tenant_id = $1
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collections (
        tenant_id, region_id, entry_id, node_id, subject_id, legacy_id,
        name, collection_type, source_type, filters, question_count,
        status, sort_order, access_rules, metadata
      )
      select q.tenant_id, s.region_id, review_node.entry_id, review_node.id,
             s.id,
             'migration_review:orphan_subject:' || s.legacy_id || ':all',
             s.name || ' · 迁移待复核题目',
             'manual', 'manual_questions',
             jsonb_build_object(
               'subjectId', s.id,
               'legacySubjectId', s.legacy_id,
               'legacyNodeIds', to_jsonb(array_remove(array_agg(distinct q.legacy_node_id), null)),
               'requiresReview', true
             ),
             0,
             'draft', 999999,
             jsonb_build_object('requiresReview', true),
             jsonb_build_object(
               'source', 'pocketbase.migration_review.orphan_subject',
               'reviewRequired', true,
               'reason', 'legacy_node_or_subject_module_missing'
             )
      from public.questions q
      join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
      join public.content_nodes review_node
        on review_node.tenant_id = s.tenant_id
       and review_node.legacy_id = 'migration_review:orphan_subject:' || s.legacy_id
      where q.tenant_id = $1
        and q.legacy_id is not null
        and q.status = 'published'
        and q.node_id is null
        and q.category_id is null
        and q.legacy_category_id is null
        and q.legacy_node_id is not null
      group by q.tenant_id, s.region_id, review_node.entry_id, review_node.id, s.id, s.legacy_id, s.name
      on conflict (tenant_id, legacy_id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    node_id = excluded.node_id,
                    subject_id = excluded.subject_id,
                    name = excluded.name,
                    collection_type = excluded.collection_type,
                    source_type = excluded.source_type,
                    filters = excluded.filters,
                    status = excluded.status,
                    sort_order = excluded.sort_order,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collections (
        tenant_id, region_id, entry_id, node_id, subject_id, legacy_id,
        name, collection_type, source_type, filters, question_count,
        status, sort_order, access_rules, metadata
      )
      select s.tenant_id, s.region_id, subject_node.entry_id, subject_node.id, s.id,
             'subject:' || s.legacy_id || ':all',
             s.name || ' · 全部题目',
             'dynamic', 'subject',
             jsonb_build_object(
               'subjectId', s.id,
               'legacySubjectId', s.legacy_id,
               'includeMigrationReview', false
             ),
             0,
             case when s.is_active then 'active' else 'draft' end,
             s.sort_order, '{}'::jsonb,
             jsonb_build_object('source', 'pocketbase.subjects', 'scope', 'subject_all')
      from public.subjects s
      join public.content_nodes subject_node
        on subject_node.tenant_id = s.tenant_id
       and subject_node.legacy_id = 'subject:' || s.legacy_id
      where s.tenant_id = $1
        and s.legacy_id is not null
      on conflict (tenant_id, legacy_id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    node_id = excluded.node_id,
                    subject_id = excluded.subject_id,
                    name = excluded.name,
                    collection_type = excluded.collection_type,
                    source_type = excluded.source_type,
                    filters = excluded.filters,
                    status = excluded.status,
                    sort_order = excluded.sort_order,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collections (
        tenant_id, region_id, entry_id, node_id, subject_id, category_id,
        legacy_id, name, collection_type, source_type, filters, question_count,
        status, sort_order, access_rules, metadata
      )
      select c.tenant_id, s.region_id, category_node.entry_id, category_node.id,
             s.id, c.id,
             'category:' || c.legacy_id || ':all',
             c.name,
             case when c.category_type = 'paper' then 'paper' else 'chapter' end,
             'category',
             jsonb_build_object(
               'categoryId', c.id,
               'legacyCategoryId', c.legacy_id,
               'subjectId', s.id,
               'legacySubjectId', s.legacy_id
             ),
             0,
             case when c.is_active then 'active' else 'draft' end,
             c.sort_order, '{}'::jsonb,
             jsonb_build_object('source', 'pocketbase.categories', 'scope', 'category')
      from public.categories c
      join public.subjects s on s.id = c.subject_id and s.tenant_id = c.tenant_id
      join public.content_nodes category_node
        on category_node.tenant_id = c.tenant_id
       and category_node.legacy_id = 'category:' || c.legacy_id
      where c.tenant_id = $1
        and c.legacy_id is not null
      on conflict (tenant_id, legacy_id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    node_id = excluded.node_id,
                    subject_id = excluded.subject_id,
                    category_id = excluded.category_id,
                    name = excluded.name,
                    collection_type = excluded.collection_type,
                    source_type = excluded.source_type,
                    filters = excluded.filters,
                    status = excluded.status,
                    sort_order = excluded.sort_order,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      with direct_node_stats as (
        select mn.tenant_id, cn.region_id, cn.entry_id, cn.id as content_node_id,
               mn.id as module_node_id, mn.legacy_id, mn.name, mn.type,
               mn.sort_order, mn.is_active,
               case
                 when count(distinct q.subject_id) filter (where q.subject_id is not null) = 1
                 then min(q.subject_id::text)::uuid
                 else null
               end as subject_id,
               count(q.id)::integer as question_count
        from public.module_nodes mn
        join public.content_nodes cn
          on cn.tenant_id = mn.tenant_id
         and cn.legacy_id = 'module_node:' || mn.legacy_id
        join public.questions q
          on q.tenant_id = mn.tenant_id
         and q.node_id = mn.id
         and q.status = 'published'
        where mn.tenant_id = $1
          and mn.legacy_id is not null
        group by mn.tenant_id, cn.region_id, cn.entry_id, cn.id,
                 mn.id, mn.legacy_id, mn.name, mn.type, mn.sort_order, mn.is_active
      )
      insert into public.question_collections (
        tenant_id, region_id, entry_id, node_id, subject_id, legacy_id,
        name, collection_type, source_type, filters, question_count,
        status, sort_order, access_rules, metadata
      )
      select tenant_id, region_id, entry_id, content_node_id, subject_id,
             'module_node:' || legacy_id || ':direct',
             name,
             case
               when type = 'paper' then 'paper'
               when type = 'chapter' then 'chapter'
               else 'dynamic'
             end,
             'filters',
             jsonb_build_object(
               'contentNodeId', content_node_id,
               'legacyNodeId', legacy_id,
               'moduleNodeId', module_node_id,
               'directOnly', true
             ),
             question_count,
             case when is_active then 'active' else 'draft' end,
             sort_order, '{}'::jsonb,
             jsonb_build_object(
               'source', 'pocketbase.module_nodes',
               'scope', 'node_direct',
               'legacyNodeId', legacy_id,
               'questionCount', question_count
             )
      from direct_node_stats
      on conflict (tenant_id, legacy_id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    node_id = excluded.node_id,
                    subject_id = excluded.subject_id,
                    name = excluded.name,
                    collection_type = excluded.collection_type,
                    source_type = excluded.source_type,
                    filters = excluded.filters,
                    question_count = excluded.question_count,
                    status = excluded.status,
                    sort_order = excluded.sort_order,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collections (
        tenant_id, region_id, entry_id, node_id, subject_id, legacy_id,
        name, collection_type, source_type, filters, question_count,
        status, sort_order, access_rules, metadata
      )
      select q.tenant_id, s.region_id, review_node.entry_id, review_node.id,
             s.id,
             'migration_review:unresolved_category:' || s.legacy_id || ':all',
             s.name || ' · 迁移待复核题目',
             'manual', 'manual_questions',
             jsonb_build_object(
               'subjectId', s.id,
               'legacySubjectId', s.legacy_id,
               'requiresReview', true
             ),
             0,
             'draft', 999999,
             jsonb_build_object('requiresReview', true),
             jsonb_build_object(
               'source', 'pocketbase.migration_review.unresolved_category',
               'reviewRequired', true,
               'reason', 'legacy_category_or_node_missing'
             )
      from public.questions q
      join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
      join public.content_nodes review_node
        on review_node.tenant_id = s.tenant_id
       and review_node.legacy_id = 'migration_review:unresolved_category:' || s.legacy_id
      where q.tenant_id = $1
        and q.category_id is null
        and q.node_id is null
        and q.legacy_category_id is not null
      group by q.tenant_id, s.region_id, review_node.entry_id, review_node.id, s.id, s.legacy_id, s.name
      on conflict (tenant_id, legacy_id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    node_id = excluded.node_id,
                    subject_id = excluded.subject_id,
                    name = excluded.name,
                    collection_type = excluded.collection_type,
                    source_type = excluded.source_type,
                    filters = excluded.filters,
                    status = excluded.status,
                    sort_order = excluded.sort_order,
                    access_rules = excluded.access_rules,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.questions q
      set entry_id = qc.entry_id,
          content_node_id = qc.node_id,
          primary_collection_id = qc.id,
          updated_at = now()
      from public.subjects s
      join public.question_collections qc
        on qc.tenant_id = s.tenant_id
       and qc.legacy_id = 'migration_review:orphan_subject:' || s.legacy_id || ':all'
      where q.tenant_id = $1
        and s.tenant_id = q.tenant_id
        and s.id = q.subject_id
        and q.primary_collection_id is null
        and q.legacy_id is not null
        and q.status = 'published'
        and q.node_id is null
        and q.category_id is null
        and q.legacy_category_id is null
        and q.legacy_node_id is not null
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.questions q
      set entry_id = qc.entry_id,
          content_node_id = qc.node_id,
          primary_collection_id = qc.id,
          updated_at = now()
      from public.module_nodes mn
      join public.content_nodes cn
        on cn.tenant_id = mn.tenant_id
       and cn.legacy_id = 'module_node:' || mn.legacy_id
      join public.question_collections qc
        on qc.tenant_id = mn.tenant_id
       and qc.node_id = cn.id
       and qc.legacy_id = 'module_node:' || mn.legacy_id || ':direct'
      where q.tenant_id = $1
        and mn.tenant_id = q.tenant_id
        and q.node_id = mn.id
        and q.status = 'published'
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.questions q
      set entry_id = qc.entry_id,
          content_node_id = qc.node_id,
          primary_collection_id = qc.id,
          updated_at = now()
      from public.question_collections qc
      where q.tenant_id = $1
        and qc.tenant_id = q.tenant_id
        and qc.category_id = q.category_id
        and q.category_id is not null
        and qc.metadata->>'source' = 'pocketbase.categories'
        and (q.primary_collection_id is null or q.node_id is null)
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.questions q
      set entry_id = qc.entry_id,
          content_node_id = qc.node_id,
          primary_collection_id = qc.id,
          updated_at = now()
      from public.question_collections qc
      where q.tenant_id = $1
        and qc.tenant_id = q.tenant_id
        and qc.subject_id = q.subject_id
        and q.category_id is null
        and q.node_id is null
        and q.legacy_category_id is not null
        and qc.metadata->>'source' = 'pocketbase.migration_review.unresolved_category'
        and q.primary_collection_id is null
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.questions q
      set entry_id = qc.entry_id,
          content_node_id = qc.node_id,
          primary_collection_id = qc.id,
          updated_at = now()
      from public.question_collections qc
      where q.tenant_id = $1
        and qc.tenant_id = q.tenant_id
        and qc.subject_id = q.subject_id
        and q.primary_collection_id is null
        and qc.metadata->>'source' = 'pocketbase.subjects'
        and qc.metadata->>'scope' = 'subject_all'
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collection_items (
        tenant_id, collection_id, question_id, sort_order, metadata
      )
      select q.tenant_id, qc.id, q.id,
             row_number() over (partition by qc.id order by q.created_at asc, q.id asc)::integer - 1,
             jsonb_build_object(
               'source', 'pocketbase.navigation.orphan_subject_review',
               'legacyNodeId', q.legacy_node_id
             )
      from public.questions q
      join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
      join public.question_collections qc
        on qc.tenant_id = q.tenant_id
       and qc.legacy_id = 'migration_review:orphan_subject:' || s.legacy_id || ':all'
       and qc.metadata->>'source' = 'pocketbase.migration_review.orphan_subject'
      where q.tenant_id = $1
        and q.node_id is null
        and q.category_id is null
        and q.legacy_category_id is null
        and q.legacy_node_id is not null
        and q.status = 'published'
      on conflict (tenant_id, collection_id, question_id)
      do update set sort_order = excluded.sort_order,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collection_items (
        tenant_id, collection_id, question_id, sort_order, metadata
      )
      select q.tenant_id, qc.id, q.id,
             row_number() over (partition by qc.id order by q.created_at asc, q.id asc)::integer - 1,
             jsonb_build_object(
               'source', 'pocketbase.navigation.module_node',
               'legacyNodeId', mn.legacy_id
             )
      from public.questions q
      join public.module_nodes mn
        on mn.tenant_id = q.tenant_id
       and mn.id = q.node_id
      join public.question_collections qc
        on qc.tenant_id = q.tenant_id
       and qc.legacy_id = 'module_node:' || mn.legacy_id || ':direct'
       and qc.metadata->>'source' = 'pocketbase.module_nodes'
      where q.tenant_id = $1
        and q.status = 'published'
      on conflict (tenant_id, collection_id, question_id)
      do update set sort_order = excluded.sort_order,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collection_items (
        tenant_id, collection_id, question_id, sort_order, metadata
      )
      select q.tenant_id, qc.id, q.id,
             row_number() over (partition by qc.id order by q.created_at asc, q.id asc)::integer - 1,
             jsonb_build_object('source', 'pocketbase.navigation.category')
      from public.questions q
      join public.question_collections qc
        on qc.tenant_id = q.tenant_id
       and qc.category_id = q.category_id
       and qc.metadata->>'source' = 'pocketbase.categories'
      where q.tenant_id = $1
        and q.category_id is not null
        and q.status = 'published'
      on conflict (tenant_id, collection_id, question_id)
      do update set sort_order = excluded.sort_order,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collection_items (
        tenant_id, collection_id, question_id, sort_order, metadata
      )
      select q.tenant_id, qc.id, q.id,
             row_number() over (partition by qc.id order by q.created_at asc, q.id asc)::integer - 1,
             jsonb_build_object('source', 'pocketbase.navigation.subject_all')
      from public.questions q
      join public.question_collections qc
        on qc.tenant_id = q.tenant_id
       and qc.subject_id = q.subject_id
       and qc.metadata->>'source' = 'pocketbase.subjects'
       and qc.metadata->>'scope' = 'subject_all'
      where q.tenant_id = $1
        and q.status = 'published'
        and (q.category_id is not null or q.legacy_category_id is null or q.node_id is not null)
      on conflict (tenant_id, collection_id, question_id)
      do update set sort_order = excluded.sort_order,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.question_collection_items (
        tenant_id, collection_id, question_id, sort_order, metadata
      )
      select q.tenant_id, qc.id, q.id,
             row_number() over (partition by qc.id order by q.created_at asc, q.id asc)::integer - 1,
             jsonb_build_object(
               'source', 'pocketbase.navigation.migration_review',
               'legacyCategoryId', q.legacy_category_id
             )
      from public.questions q
      join public.question_collections qc
        on qc.tenant_id = q.tenant_id
       and qc.subject_id = q.subject_id
       and qc.metadata->>'source' = 'pocketbase.migration_review.unresolved_category'
      where q.tenant_id = $1
        and q.category_id is null
        and q.node_id is null
        and q.legacy_category_id is not null
        and q.status = 'published'
      on conflict (tenant_id, collection_id, question_id)
      do update set sort_order = excluded.sort_order,
                    metadata = excluded.metadata,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.question_collections qc
      set question_count = coalesce(counts.count, 0),
          updated_at = now()
      from (
        select collection_id, count(*)::integer as count
        from public.question_collection_items
        where tenant_id = $1
        group by collection_id
      ) counts
      where qc.tenant_id = $1
        and qc.id = counts.collection_id
      `,
      [tenantId],
    );

    await client.query(
      `
      update public.question_collections qc
      set question_count = 0,
          updated_at = now()
      where qc.tenant_id = $1
        and not exists (
          select 1
          from public.question_collection_items item
          where item.tenant_id = qc.tenant_id
            and item.collection_id = qc.id
        )
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.practice_blueprints (
        tenant_id, region_id, entry_id, node_id, collection_id, legacy_id,
        name, mode, assembly_type, question_limit, rules, status,
        sort_order, access_rules
      )
      select qc.tenant_id, qc.region_id, qc.entry_id, qc.node_id, qc.id,
             qc.legacy_id || ':sequential',
             qc.name || ' · 顺序刷题',
             'sequential', 'collection', null,
             jsonb_build_object('source', 'pocketbase.navigation', 'collectionLegacyId', qc.legacy_id),
             qc.status, qc.sort_order, qc.access_rules
      from public.question_collections qc
      where qc.tenant_id = $1
        and qc.question_count > 0
        and qc.legacy_id is not null
      on conflict (tenant_id, legacy_id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    node_id = excluded.node_id,
                    collection_id = excluded.collection_id,
                    name = excluded.name,
                    mode = excluded.mode,
                    assembly_type = excluded.assembly_type,
                    question_limit = excluded.question_limit,
                    rules = excluded.rules,
                    status = excluded.status,
                    sort_order = excluded.sort_order,
                    access_rules = excluded.access_rules,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.practice_blueprints (
        tenant_id, region_id, entry_id, node_id, collection_id, legacy_id,
        name, mode, assembly_type, question_limit, rules, status,
        sort_order, access_rules
      )
      select qc.tenant_id, qc.region_id, qc.entry_id, qc.node_id, qc.id,
             qc.legacy_id || ':random',
             qc.name || ' · 随机刷题',
             'random', 'collection', least(20, qc.question_count),
             jsonb_build_object('source', 'pocketbase.navigation', 'collectionLegacyId', qc.legacy_id),
             qc.status, qc.sort_order + 1, qc.access_rules
      from public.question_collections qc
      where qc.tenant_id = $1
        and qc.question_count > 0
        and qc.legacy_id is not null
      on conflict (tenant_id, legacy_id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    node_id = excluded.node_id,
                    collection_id = excluded.collection_id,
                    name = excluded.name,
                    mode = excluded.mode,
                    assembly_type = excluded.assembly_type,
                    question_limit = excluded.question_limit,
                    rules = excluded.rules,
                    status = excluded.status,
                    sort_order = excluded.sort_order,
                    access_rules = excluded.access_rules,
                    updated_at = now()
      `,
      [tenantId],
    );

    await client.query(
      `
      insert into public.pb_import_issues (
        run_id, tenant_id, collection_name, legacy_id, severity, issue_code, message
      )
      values ($1,$2,$3,$4,$5,$6,$7)
      `,
      [
        runId,
        tenantId,
        'questions',
        '__migration_navigation__',
        'info',
        'practice_navigation_built',
        'Legacy question practice entries, nodes, collections and blueprints were generated for the new SaaS navigation model.',
      ],
    );

    await client.query('commit');
  } catch (error) {
    await client.query('rollback');
    throw error;
  } finally {
    client.release();
  }

  console.log('practice_navigation: generated content entries, nodes, collections and blueprints');
}

async function normalizeUserAnswerRecords(runId: string, records: JsonRecord[]) {
  const userIdsByLegacy = await bulkUserIdsByLegacy(records.map(record => record.userId));
  const questionIdsByLegacy = await bulkLegacyIds('questions', records.map(record => record.questionId));
  const questionVersionIdsByQuestion = await bulkQuestionVersionIds([...questionIdsByLegacy.values()]);
  const issueRows: Array<{ legacyId: string | null; issue: Issue }> = [];

  for (let offset = 0; offset < records.length; offset += importBatchSize) {
    const batch = records.slice(offset, offset + importBatchSize);
    const userIds: string[] = [];
    const questionIds: Array<string | null> = [];
    const questionVersionIds: Array<string | null> = [];
    const legacyIds: string[] = [];
    const legacyQuestionIds: Array<string | null> = [];
    const legacyCategoryIds: Array<string | null> = [];
    const selectedOptionsPayloads: string[] = [];
    const answerPayloads: string[] = [];
    const isCorrectValues: Array<boolean | null> = [];
    const answeredAtValues: string[] = [];
    const createdAtValues: string[] = [];

    for (const r of batch) {
      const legacyIdValue = text(r.id);
      if (!legacyIdValue) continue;

      const legacyUserId = text(r.userId);
      const userId = legacyUserId ? userIdsByLegacy.get(legacyUserId) || null : null;
      if (!userId) {
        const hasLegacyUserId = Boolean(legacyUserId);
        issueRows.push({
          legacyId: legacyIdValue,
          issue: {
            severity: 'warning',
            issueCode: hasLegacyUserId ? 'answer_user_not_found' : 'answer_user_empty',
            message: hasLegacyUserId
              ? `Answer record skipped because legacy userId no longer exists in exported users: ${legacyUserId}`
              : 'Answer record skipped because legacy userId is empty.',
          },
        });
        continue;
      }

      const legacyQuestionId = text(r.questionId);
      const questionId = legacyQuestionId ? questionIdsByLegacy.get(legacyQuestionId) || null : null;
      if (!questionId) {
        issueRows.push({
          legacyId: legacyIdValue,
          issue: {
            severity: 'warning',
            issueCode: 'answer_question_not_found',
            message: `Answer record kept with legacy_question_id only because questionId was not resolved: ${legacyQuestionId || '(empty)'}`,
          },
        });
      }

      const isCorrect = r.isCorrect === null || r.isCorrect === undefined || r.isCorrect === ''
        ? null
        : boolValue(r.isCorrect);
      const answeredAt = nullableDateText(r.answeredAt, r.updated, r.created);
      const selectedOptions = arrayValue(r.selectedOptions);
      const answerPayload = {
        source: 'pocketbase.user_answer_records',
        selectedOptions,
        legacyCategoryId: text(r.categoryId),
        legacyQuestionId,
        importedAt: new Date().toISOString(),
      };

      userIds.push(userId);
      questionIds.push(questionId);
      questionVersionIds.push(questionId ? questionVersionIdsByQuestion.get(questionId) || null : null);
      legacyIds.push(legacyIdValue);
      legacyQuestionIds.push(legacyQuestionId);
      legacyCategoryIds.push(text(r.categoryId));
      selectedOptionsPayloads.push(JSON.stringify(selectedOptions));
      answerPayloads.push(JSON.stringify(answerPayload));
      isCorrectValues.push(isCorrect);
      answeredAtValues.push(answeredAt);
      createdAtValues.push(nullableDateText(r.created, answeredAt));
    }

    if (!legacyIds.length) continue;

    await pool.query(
      `
      insert into public.answer_records (
        tenant_id, user_id, question_id, question_version_id, legacy_id,
        legacy_question_id, legacy_category_id, selected_options,
        answer_payload, is_correct, answered_at, created_at
      )
      select $1::uuid, answer.user_id::uuid, answer.question_id::uuid,
             answer.question_version_id::uuid, answer.legacy_id,
             answer.legacy_question_id, answer.legacy_category_id,
             answer.selected_options::jsonb, answer.answer_payload::jsonb,
             answer.is_correct::boolean,
             coalesce(nullif(answer.answered_at::text,'')::timestamptz, now()),
             coalesce(nullif(answer.created_at::text,'')::timestamptz, now())
      from unnest(
        $2::text[],
        $3::text[],
        $4::text[],
        $5::text[],
        $6::text[],
        $7::text[],
        $8::text[],
        $9::text[],
        $10::boolean[],
        $11::text[],
        $12::text[]
      ) as answer(
        user_id, question_id, question_version_id, legacy_id,
        legacy_question_id, legacy_category_id, selected_options,
        answer_payload, is_correct, answered_at, created_at
      )
      on conflict (tenant_id, legacy_id) do update set
        user_id = excluded.user_id,
        question_id = excluded.question_id,
        question_version_id = excluded.question_version_id,
        legacy_question_id = excluded.legacy_question_id,
        legacy_category_id = excluded.legacy_category_id,
        selected_options = excluded.selected_options,
        answer_payload = excluded.answer_payload,
        is_correct = excluded.is_correct,
        answered_at = excluded.answered_at
      `,
      [
        tenantId,
        userIds,
        questionIds,
        questionVersionIds,
        legacyIds,
        legacyQuestionIds,
        legacyCategoryIds,
        selectedOptionsPayloads,
        answerPayloads,
        isCorrectValues,
        answeredAtValues,
        createdAtValues,
      ],
    );
  }

  await insertImportIssueRows(runId, 'user_answer_records', issueRows);

  await pool.query(
    `
    insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
    select tenant_id, user_id, question_id, count(*)::integer, max(answered_at), null
    from public.answer_records
    where tenant_id = $1
      and legacy_id is not null
      and question_id is not null
      and is_correct = false
    group by tenant_id, user_id, question_id
    on conflict (tenant_id, user_id, question_id)
    do update set wrong_count = excluded.wrong_count,
                  last_wrong_at = excluded.last_wrong_at,
                  resolved_at = null
    `,
    [tenantId],
  );
}

async function normalizeUsers(records: JsonRecord[]) {
  for (const r of records) {
    const safeProfile = publicProfile(r);
    await pool.query(
      `
      insert into public.platform_users (
        legacy_id, username, email, phone, name, avatar_url, primary_role, score,
        last_seen_at, password_migration_required, platform_permissions, raw_profile, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,true,$10::jsonb,$11,
        coalesce(nullif($12::text,'')::timestamptz, now()),
        coalesce(nullif($13::text,'')::timestamptz, now())
      )
      on conflict (legacy_id) do update set
        username = excluded.username,
        email = excluded.email,
        phone = excluded.phone,
        name = excluded.name,
        avatar_url = excluded.avatar_url,
        primary_role = excluded.primary_role,
        score = excluded.score,
        platform_permissions = excluded.platform_permissions,
        raw_profile = excluded.raw_profile,
        updated_at = excluded.updated_at
      `,
      [
        r.id,
        text(r.username),
        text(r.email),
        text(r.phone),
        text(r.name) || text(r.username),
        null,
        normalizeTenantRole(r.role),
        intValue(r.score),
        dateText(r.lastSeenAt),
        normalizeTenantRole(r.role) === 'platform_admin' ? JSON.stringify({ '*': true }) : JSON.stringify({}),
        JSON.stringify(safeProfile),
        dateText(r.created),
        dateText(r.updated),
      ],
    );

    const user = await queryOne<{ id: string }>('select id from public.platform_users where legacy_id = $1', [r.id]);
    if (!user) continue;

    await pool.query(
      `
      insert into public.tenant_memberships (tenant_id, user_id, role, legacy_role)
      values ($1, $2, $3, $4)
      on conflict (tenant_id, user_id, role) do update set legacy_role = excluded.legacy_role
      `,
      [tenantId, user.id, normalizeTenantRole(r.role), text(r.role) || 'student'],
    );

    await pool.query(
      `
      insert into public.student_profiles (
        tenant_id, user_id, legacy_user_id, region_id, selected_school_id,
        selected_major_id, questions_answered_today, mastered_words_count,
        last_check_in_date, stats, progress, module_selections,
        recent_activities, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::date,$10,$11,$12,$13,
        coalesce(nullif($14::text,'')::timestamptz, now()),
        coalesce(nullif($15::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, user_id) do update set
        region_id = excluded.region_id,
        selected_school_id = excluded.selected_school_id,
        selected_major_id = excluded.selected_major_id,
        questions_answered_today = excluded.questions_answered_today,
        mastered_words_count = excluded.mastered_words_count,
        last_check_in_date = excluded.last_check_in_date,
        stats = excluded.stats,
        progress = excluded.progress,
        module_selections = excluded.module_selections,
        recent_activities = excluded.recent_activities,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        user.id,
        r.id,
        await legacyId('regions', r.regionId),
        await legacyId('schools', r.selectedSchoolId),
        await legacyId('majors', r.selectedMajorId),
        intValue(r.questionsAnswered),
        intValue(r.masteredWordsCount),
        dateText(r.lastCheckInDate),
        JSON.stringify(learningStats(r.stats)),
        json(r.progress, {}),
        json(r.moduleSelections, {}),
        json(r.recentActivities, []),
        dateText(r.created),
        dateText(r.updated),
      ],
    );

    for (const [key, provider] of [
      ['email', 'email'],
      ['phone', 'phone'],
      ['wechatUnionId', 'wechat_unionid'],
      ['wechatOpenId', 'wechat_openid'],
      ['qqOpenId', 'qq_openid'],
    ] as const) {
      const subject = text(r[key]);
      if (!subject) continue;
      await pool.query(
        `
        insert into public.user_identities (user_id, provider, provider_subject, union_id, open_id, phone, email)
        values ($1,$2,$3,$4,$5,$6,$7)
        on conflict (provider, provider_subject) do update set user_id = excluded.user_id
        `,
        [
          user.id,
          provider,
          subject,
          key === 'wechatUnionId' ? subject : null,
          key === 'wechatOpenId' || key === 'qqOpenId' ? subject : null,
          key === 'phone' ? subject : null,
          key === 'email' ? subject : null,
        ],
      );
    }
  }
}

async function normalizeUserEntitlementsAndStats(runId: string, records: JsonRecord[]) {
  for (const r of records) {
    const userId = await userIdByLegacy(r.id);
    if (!userId) continue;

    await pool.query(
      `
      delete from public.entitlements
      where tenant_id = $1 and user_id = $2 and source_type = 'migration' and legacy_source_id = $3
      `,
      [tenantId, userId, text(r.id)],
    );

    if (boolValue(r.isSvip) || text(r.svipExpiry) || r.svipRegions) {
      const svipRegionEntries = entriesValue(r.svipRegions);
      const scopes =
        svipRegionEntries.length > 0
          ? svipRegionEntries.map(([regionLegacyId, expiresAt]) => ({ regionLegacyId, expiresAt }))
          : [{ regionLegacyId: r.svipRegionId || r.regionId || null, expiresAt: r.svipExpiry }];

      for (const scope of scopes) {
        const scopeLegacyId = scope.regionLegacyId;
        const regionId = await legacyId('regions', scopeLegacyId);
        await pool.query(
          `
          insert into public.entitlements (
            tenant_id, user_id, entitlement_type, scope_type, scope_id, source_type,
            legacy_source_id, starts_at, expires_at, status, metadata
          )
          values ($1,$2,'svip',$3,$4,'migration',$5,
            coalesce(nullif($6::text,'')::timestamptz, now()),
            nullif($7::text,'')::timestamptz,
            'active',
            $8
          )
          `,
          [
            tenantId,
            userId,
            regionId ? 'region' : 'tenant',
            regionId,
            text(r.id),
            dateText(r.created),
            dateText(scope.expiresAt || r.svipExpiry),
            json({ legacyRegion: scopeLegacyId || null, source: 'users.svip' }, {}),
          ],
        );
      }
    }

    const stats = parseJsonish(r.stats, {}) as JsonRecord;
    for (const legacyQuestionId of arrayValue(stats.favorites)) {
      const questionId = await legacyId('questions', legacyQuestionId);
      if (!questionId) {
        await issue(runId, 'users', r.id, 'favorite_question_not_found', `Favorite question not found: ${text(legacyQuestionId)}`);
        continue;
      }
      await pool.query(
        `
        insert into public.favorite_questions (tenant_id, user_id, question_id, source)
        values ($1,$2,$3,'migration')
        on conflict (tenant_id, user_id, question_id) do nothing
        `,
        [tenantId, userId, questionId],
      );
    }

    for (const legacyQuestionId of arrayValue(stats.wrongBook)) {
      const questionId = await legacyId('questions', legacyQuestionId);
      if (!questionId) {
        await issue(runId, 'users', r.id, 'wrong_question_not_found', `Wrong-book question not found: ${text(legacyQuestionId)}`);
        continue;
      }
      await pool.query(
        `
        insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count)
        values ($1,$2,$3,1)
        on conflict (tenant_id, user_id, question_id)
        do update set wrong_count = greatest(public.wrong_questions.wrong_count, excluded.wrong_count)
        `,
        [tenantId, userId, questionId],
      );
    }
  }
}

async function normalizeSettings(records: JsonRecord[]) {
  for (const r of records) {
    const publicConfig: Record<string, unknown> = {};

    for (const [key, value] of Object.entries(r)) {
      if (['id', 'created', 'updated'].includes(key)) continue;
      if (sensitiveKeyPattern.test(key)) continue;
      if (identityKeyPattern.test(key)) continue;
      publicConfig[key] = value;
    }

    await pool.query(
      `
      insert into public.tenant_settings (tenant_id, public_config)
      values ($1, $2)
      on conflict (tenant_id) do update set
        public_config = public.tenant_settings.public_config || excluded.public_config,
        updated_at = now()
      `,
      [tenantId, JSON.stringify(publicConfig)],
    );

    await upsertPaymentAccount('xunhu_alipay', {
      appId: text(r.xunhuAlipayAppId),
      notifyDomain: text(r.xunhuNotifyDomain),
      returnUrl: text(r.xunhuReturnUrl),
    });
    await upsertPaymentAccount('xunhu_wechat', {
      appId: text(r.xunhuWechatAppId),
      notifyDomain: text(r.xunhuNotifyDomain),
      returnUrl: text(r.xunhuReturnUrl),
    });
    await upsertPaymentAccount('wechat_pay', {
      appId: text(r.wxMiniAppId),
      mchId: text(r.wxMchId),
      notifyUrl: text(r.wxNotifyUrl),
      serialNo: text(r.wxSerialNo),
    });
    await upsertPaymentAccount('xpay', {
      offerId: text(r.xpayOfferId),
      env: text(r.xpayEnv),
      enabled: boolValue(r.xpayEnabled),
    });

    for (const [key, value] of Object.entries(r)) {
      if (!sensitiveKeyPattern.test(key) || value === null || value === undefined || value === '') continue;
      const lower = key.toLowerCase();
      const scope = lower.includes('sms')
        ? 'sms'
        : lower.includes('pay') || lower.includes('xpay') || lower.includes('mchid') || lower.includes('serial')
          ? 'payment'
          : lower.includes('s3')
            ? 'storage'
            : lower.includes('wechat') || lower.includes('qq') || lower.includes('wx')
              ? 'oauth'
              : 'system';
      await upsertSecret(scope, key, value);
    }
  }
}

async function upsertPaymentAccount(provider: string, publicConfig: Record<string, unknown>) {
  const hasAnyValue = Object.values(publicConfig).some(value => value !== null && value !== undefined && value !== '');
  if (!hasAnyValue) return;
  await pool.query(
    `
    insert into public.tenant_payment_accounts (tenant_id, provider, mode, display_name, status, config_public)
    values ($1,$2,'tenant_collect',$3,'pending',$4)
    on conflict (tenant_id, provider) do update set
      config_public = public.tenant_payment_accounts.config_public || excluded.config_public,
      updated_at = now()
    `,
    [tenantId, provider, provider, JSON.stringify(publicConfig)],
  );
}

async function normalizeSvipPlans(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.svip_plans (
        tenant_id, region_id, legacy_id, name, price_cents, original_price_cents,
        days, description, per_day_label, badge, recommended, coupon_only,
        vp_product_id, vp_enabled, sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,
        coalesce(nullif($16::text,'')::timestamptz, now()),
        coalesce(nullif($17::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        name = excluded.name,
        price_cents = excluded.price_cents,
        original_price_cents = excluded.original_price_cents,
        days = excluded.days,
        description = excluded.description,
        per_day_label = excluded.per_day_label,
        badge = excluded.badge,
        recommended = excluded.recommended,
        coupon_only = excluded.coupon_only,
        vp_product_id = excluded.vp_product_id,
        vp_enabled = excluded.vp_enabled,
        sort_order = excluded.sort_order,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        r.id,
        text(r.name) || '未命名套餐',
        cents(r.price),
        r.originalPrice === undefined ? null : cents(r.originalPrice),
        intValue(r.days),
        text(r.desc),
        text(r.perDay),
        text(r.badge),
        boolValue(r.recommended),
        boolValue(r.couponOnly),
        text(r.vpProductId),
        boolValue(r.vpEnabled),
        intValue(r.order),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

function normalizeMockExamSections(questionTypes: unknown) {
  return arrayValue(questionTypes)
    .map((item, index) => {
      const object = objectValue(item);
      const questionType = text(object.type) || text(object.questionType) || `section_${index + 1}`;
      const count = intValue(object.count, 0);
      return {
        key: text(object.key) || questionType,
        title: text(object.title) || text(object.name) || questionType,
        questionType,
        count,
        sortOrder: intValue(object.order, index + 1) - 1,
        scoreEach: numberValue(object.scoreEach ?? object.score, null),
      };
    })
    .filter(section => section.count > 0)
    .sort((a, b) => a.sortOrder - b.sortOrder);
}

async function normalizeMockExamConfigs(runId: string, records: JsonRecord[]) {
  for (const r of records) {
    const subjectId = await legacyId('subjects', r.subjectId);
    const sections = normalizeMockExamSections(r.questionTypes);
    const questionLimit = intValue(r.totalQuestions, sections.reduce((sum, section) => sum + section.count, 0));
    if (!subjectId) {
      await issue(
        runId,
        'mock_exam_configs',
        r.id,
        'mock_exam_subject_not_found',
        `Mock exam blueprint imported without resolved subject because subjectId was not found: ${text(r.subjectId) || '(empty)'}`,
        'warning',
      );
    }

    await pool.query(
      `
      insert into public.practice_blueprints (
        tenant_id, legacy_id, name, mode, assembly_type, question_limit,
        duration_minutes, sections, rules, status, sort_order, created_at, updated_at
      )
      values ($1,$2,$3,'mock_exam','filters',$4,$5,$6::jsonb,$7::jsonb,$8,$9,
        coalesce(nullif($10::text,'')::timestamptz, now()),
        coalesce(nullif($11::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        name = excluded.name,
        question_limit = excluded.question_limit,
        duration_minutes = excluded.duration_minutes,
        sections = excluded.sections,
        rules = excluded.rules,
        status = excluded.status,
        sort_order = excluded.sort_order,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        text(r.name) || `全真模拟-${text(r.subjectId) || text(r.id) || '未命名'}`,
        questionLimit > 0 ? questionLimit : null,
        intValue(r.duration, 45),
        JSON.stringify(sections),
        JSON.stringify({
          source: 'pocketbase.mock_exam_configs',
          subjectId,
          legacySubjectId: text(r.subjectId),
          legacyQuestionTypes: arrayValue(r.questionTypes),
          randomize: true,
        }),
        boolValue(r.isActive, true) ? 'active' : 'archived',
        intValue(r.order),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeCodeBatches(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.code_batches (
        tenant_id, legacy_id, name, sale_type, channel, campaign_name,
        default_unit_price_cents, cost_price_cents, total_count, days,
        region_id, legacy_region_id, issued_at, created_by, remark,
        commission_rate, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,nullif($13::text,'')::timestamptz,$14,$15,$16,
        coalesce(nullif($17::text,'')::timestamptz, now()),
        coalesce(nullif($18::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        name = excluded.name,
        sale_type = excluded.sale_type,
        channel = excluded.channel,
        campaign_name = excluded.campaign_name,
        default_unit_price_cents = excluded.default_unit_price_cents,
        cost_price_cents = excluded.cost_price_cents,
        total_count = excluded.total_count,
        days = excluded.days,
        region_id = excluded.region_id,
        legacy_region_id = excluded.legacy_region_id,
        issued_at = excluded.issued_at,
        created_by = excluded.created_by,
        remark = excluded.remark,
        commission_rate = excluded.commission_rate,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        text(r.name) || '未命名批次',
        text(r.saleType),
        text(r.channel),
        text(r.campaignName),
        cents(r.defaultUnitPrice),
        cents(r.costPrice),
        intValue(r.totalCount),
        numberValue(r.days, null),
        await legacyId('regions', r.regionId),
        text(r.regionId),
        dateText(r.issuedAt),
        await userIdByLegacy(r.createdBy),
        text(r.remark),
        numberValue(r.commissionRate, null),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeActivationCodes(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.activation_codes (
        tenant_id, legacy_id, code, days, is_used, used_by, used_at, agent_user_id,
        batch_id, sale_type, unit_price_cents, sold_to, used_region_id,
        coupon_code, coupon_redemption_id, remark, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz,$8,$9,$10,$11,$12,$13,$14,$15,$16,
        coalesce(nullif($17::text,'')::timestamptz, now()),
        coalesce(nullif($18::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        code = excluded.code,
        days = excluded.days,
        is_used = excluded.is_used,
        used_by = excluded.used_by,
        used_at = excluded.used_at,
        agent_user_id = excluded.agent_user_id,
        batch_id = excluded.batch_id,
        sale_type = excluded.sale_type,
        unit_price_cents = excluded.unit_price_cents,
        sold_to = excluded.sold_to,
        used_region_id = excluded.used_region_id,
        coupon_code = excluded.coupon_code,
        coupon_redemption_id = excluded.coupon_redemption_id,
        remark = excluded.remark,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        text(r.code) || `legacy-${text(r.id)}`,
        intValue(r.days),
        boolValue(r.isUsed),
        await userIdByLegacy(r.usedBy),
        dateText(r.usedAt),
        await userIdByLegacy(r.agentId),
        await legacyId('code_batches', r.batchId),
        text(r.saleType),
        r.unitPrice === undefined ? null : cents(r.unitPrice),
        text(r.soldTo),
        await legacyId('regions', r.usedRegionId),
        text(r.couponCode),
        await legacyId('coupon_redemptions', r.couponRedemptionId),
        text(r.remark),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeCoupons(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.coupons (
        tenant_id, legacy_id, code, plan_id, discount_type, discount_value,
        valid_from, valid_to, max_uses, used_count, source, remark,
        created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz,nullif($8::text,'')::timestamptz,$9,$10,$11,$12,
        coalesce(nullif($13::text,'')::timestamptz, now()),
        coalesce(nullif($14::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        code = excluded.code,
        plan_id = excluded.plan_id,
        discount_type = excluded.discount_type,
        discount_value = excluded.discount_value,
        valid_from = excluded.valid_from,
        valid_to = excluded.valid_to,
        max_uses = excluded.max_uses,
        used_count = excluded.used_count,
        source = excluded.source,
        remark = excluded.remark,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        text(r.code) || `legacy-${text(r.id)}`,
        await legacyId('svip_plans', r.planId),
        normalizeCouponType(r.discountType),
        numberValue(r.discountValue, null),
        dateText(r.validFrom),
        dateText(r.validTo),
        numberValue(r.maxUses, null),
        intValue(r.usedCount),
        text(r.source),
        text(r.remark),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeCouponRedemptions(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.coupon_redemptions (
        tenant_id, legacy_id, coupon_id, coupon_code, user_id, plan_id, order_id,
        status, discount_applied_cents, region_id, source, remark, claimed_at,
        used_at, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
        nullif($13::text,'')::timestamptz,
        nullif($14::text,'')::timestamptz,
        coalesce(nullif($15::text,'')::timestamptz, now()),
        coalesce(nullif($16::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        coupon_id = excluded.coupon_id,
        coupon_code = excluded.coupon_code,
        user_id = excluded.user_id,
        plan_id = excluded.plan_id,
        order_id = excluded.order_id,
        status = excluded.status,
        discount_applied_cents = excluded.discount_applied_cents,
        region_id = excluded.region_id,
        source = excluded.source,
        remark = excluded.remark,
        claimed_at = excluded.claimed_at,
        used_at = excluded.used_at,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        await legacyId('coupons', r.couponId),
        text(r.couponCode),
        await userIdByLegacy(r.userId),
        await legacyId('svip_plans', r.planId),
        await legacyId('orders', r.orderId),
        text(r.status) || 'claimed',
        r.discountApplied === undefined ? null : cents(r.discountApplied),
        await legacyId('regions', r.regionId),
        text(r.source),
        text(r.remark),
        dateText(r.claimedAt),
        dateText(r.usedAt),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeOrders(runId: string, records: JsonRecord[]) {
  for (const r of records) {
    const resolvedUserId = await userIdByLegacy(r.userId);
    const sanitized = sanitizeRecord(r);
    const rawPayload = {
      ...(sanitized as Record<string, unknown>),
      migration: {
        source: 'pocketbase.orders',
        reviewRequired: !resolvedUserId,
        reviewReason: !resolvedUserId ? 'legacy_user_not_resolved' : null,
        entitlementBlocked: !resolvedUserId,
      },
    };
    const order = await queryOne<{ id: string }>(
      `
      insert into public.orders (
        tenant_id, user_id, legacy_id, legacy_user_id, order_no, status,
        product_type, product_name, amount_cents, pay_method, pay_provider,
        trade_no, plan_id, legacy_plan_id, days, region_id, legacy_region_id,
        paid_at, raw_payload, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,'svip',$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
        nullif($17::text,'')::timestamptz,$18,
        coalesce(nullif($19::text,'')::timestamptz, now()),
        coalesce(nullif($20::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        user_id = excluded.user_id,
        legacy_user_id = excluded.legacy_user_id,
        order_no = excluded.order_no,
        status = excluded.status,
        product_name = excluded.product_name,
        amount_cents = excluded.amount_cents,
        pay_method = excluded.pay_method,
        pay_provider = excluded.pay_provider,
        trade_no = excluded.trade_no,
        plan_id = excluded.plan_id,
        legacy_plan_id = excluded.legacy_plan_id,
        days = excluded.days,
        region_id = excluded.region_id,
        legacy_region_id = excluded.legacy_region_id,
        paid_at = excluded.paid_at,
        raw_payload = excluded.raw_payload,
        updated_at = excluded.updated_at
      returning id
      `,
      [
        tenantId,
        resolvedUserId,
        r.id,
        text(r.userId),
        text(r.orderNo) || `legacy-${text(r.id)}`,
        normalizeOrderStatus(r.status),
        text(r.planName),
        cents(r.amount),
        text(r.payMethod),
        text(r.payProvider),
        text(r.tradeNo) || text(r.xpayWxOrderId) || text(r.xpayChannelOrderId),
        await legacyId('svip_plans', r.planId),
        text(r.planId),
        numberValue(r.days, null),
        await legacyId('regions', r.regionId),
        text(r.regionId),
        dateText(r.paidAt),
        JSON.stringify(rawPayload),
        dateText(r.created),
        dateText(r.updated),
      ],
    );

    if (!order) continue;
    if (!resolvedUserId) {
      await issue(
        runId,
        'orders',
        r.id,
        'order_user_not_resolved',
        `Order imported for finance review only because userId was not resolved: ${text(r.userId) || '(empty)'}`,
        normalizeOrderStatus(r.status) === 'paid' ? 'critical' : 'warning',
      );
    }

    await pool.query(
      `
      insert into public.order_items (
        tenant_id, order_id, legacy_id, item_type, item_id, name,
        quantity, unit_amount_cents, total_amount_cents, metadata
      )
      values ($1,$2,$3,'svip_plan',$4,$5,1,$6,$6,$7)
      on conflict (tenant_id, legacy_id) do update set
        order_id = excluded.order_id,
        item_id = excluded.item_id,
        name = excluded.name,
        unit_amount_cents = excluded.unit_amount_cents,
        total_amount_cents = excluded.total_amount_cents,
        metadata = excluded.metadata
      `,
      [
        tenantId,
        order.id,
        r.id,
        await legacyId('svip_plans', r.planId),
        text(r.planName) || 'SVIP会员',
        cents(r.amount),
        json({ legacyPlanId: text(r.planId), days: numberValue(r.days, null) }, {}),
      ],
    );

    await pool.query(
      `
      insert into public.payments (
        tenant_id, order_id, legacy_id, legacy_order_id, provider, method, status,
        amount_cents, provider_trade_no, paid_at, raw_payload, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,$11,
        coalesce(nullif($12::text,'')::timestamptz, now()),
        coalesce(nullif($13::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        provider = excluded.provider,
        method = excluded.method,
        status = excluded.status,
        amount_cents = excluded.amount_cents,
        provider_trade_no = excluded.provider_trade_no,
        paid_at = excluded.paid_at,
        raw_payload = excluded.raw_payload,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        order.id,
        r.id,
        text(r.id),
        text(r.payProvider) || text(r.payMethod) || 'legacy',
        text(r.payMethod),
        normalizePaymentStatus(r.status),
        cents(r.amount),
        text(r.tradeNo) || text(r.xpayWxOrderId) || text(r.xpayChannelOrderId),
        dateText(r.paidAt),
        JSON.stringify(sanitizeRecord(r)),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeVocabularyUnits(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.vocabulary_units (
        tenant_id, region_id, legacy_id, name, description, word_count,
        sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        name = excluded.name,
        description = excluded.description,
        word_count = excluded.word_count,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        r.id,
        text(r.name) || '未命名单元',
        text(r.description),
        numberValue(r.wordCount, null),
        intValue(r.order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeVocabularyWords(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.vocabulary_words (
        tenant_id, unit_id, legacy_id, word, phonetic, meaning, example,
        example_translation, difficulty, tags, sort_order, is_active,
        created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
        coalesce(nullif($13::text,'')::timestamptz, now()),
        coalesce(nullif($14::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        unit_id = excluded.unit_id,
        word = excluded.word,
        phonetic = excluded.phonetic,
        meaning = excluded.meaning,
        example = excluded.example,
        example_translation = excluded.example_translation,
        difficulty = excluded.difficulty,
        tags = excluded.tags,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('vocabulary_units', r.unit),
        r.id,
        text(r.word) || 'unknown',
        text(r.phonetic),
        text(r.meaning),
        text(r.example),
        text(r.exampleTranslation),
        numberValue(r.difficulty, null),
        json(r.tags, []),
        intValue(r.order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeHandbookSubjects(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.handbook_subjects (
        tenant_id, region_id, legacy_id, name, type, icon, color, description,
        sort_order, is_active, metadata, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,
        coalesce(nullif($12::text,'')::timestamptz, now()),
        coalesce(nullif($13::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        name = excluded.name,
        type = excluded.type,
        icon = excluded.icon,
        color = excluded.color,
        description = excluded.description,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        metadata = excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        r.id,
        text(r.name) || '未命名手册科目',
        text(r.type),
        text(r.icon),
        text(r.color),
        text(r.description),
        intValue(r.order),
        boolValue(r.isActive, true),
        json({ legacySchoolId: text(r.schoolId), legacyMajorId: text(r.majorId), legacyMajorIds: arrayValue(r.majorIds) }, {}),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function migrationOrphanHandbookSubjectId(): Promise<string> {
  const legacyIdValue = '__migration_orphan_handbook_subject__';
  const row = await queryOne<{ id: string }>(
    `
    insert into public.handbook_subjects (
      tenant_id, legacy_id, name, type, description, sort_order, is_active, metadata
    )
    values ($1,$2,'迁移待复核手册','migration_review','旧 PocketBase 章节缺少 subjectId 时自动挂载到这里，需人工归并。',999999,true,$3::jsonb)
    on conflict (tenant_id, legacy_id) do update set
      name = excluded.name,
      description = excluded.description,
      metadata = public.handbook_subjects.metadata || excluded.metadata,
      updated_at = now()
    returning id
    `,
    [
      tenantId,
      legacyIdValue,
      JSON.stringify({
        source: 'pocketbase.handbook_chapters',
        reviewRequired: true,
        reviewReason: 'legacy_chapter_subject_missing',
      }),
    ],
  );
  if (!row) throw new Error('Failed to create migration orphan handbook subject');
  return row.id;
}

async function normalizeHandbookChapters(runId: string, records: JsonRecord[]) {
  for (const r of records) {
    let subjectId = await legacyId('handbook_subjects', r.subjectId);
    const missingSubject = !subjectId;
    if (missingSubject) {
      subjectId = await migrationOrphanHandbookSubjectId();
      await issue(
        runId,
        'handbook_chapters',
        r.id,
        'handbook_chapter_subject_missing',
        `Handbook chapter was attached to migration review subject because subjectId was missing or unresolved: ${text(r.subjectId) || '(empty)'}`,
        'critical',
      );
    }

    await pool.query(
      `
      insert into public.handbook_chapters (
        tenant_id, subject_id, legacy_id, name, description, sort_order,
        is_active, metadata, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        subject_id = excluded.subject_id,
        name = excluded.name,
        description = excluded.description,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        metadata = excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        subjectId,
        r.id,
        text(r.name) || '未命名手册章节',
        text(r.description),
        intValue(r.order),
        boolValue(r.isActive, true),
        JSON.stringify({
          source: 'pocketbase.handbook_chapters',
          legacySubjectId: text(r.subjectId),
          reviewRequired: missingSubject,
          reviewReason: missingSubject ? 'legacy_subject_missing_or_unresolved' : null,
        }),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeHandbookEntries(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.handbook_entries (
        tenant_id, chapter_id, legacy_id, title, summary, content, tags,
        sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,
        coalesce(nullif($10::text,'')::timestamptz, now()),
        coalesce(nullif($11::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        chapter_id = excluded.chapter_id,
        title = excluded.title,
        summary = excluded.summary,
        content = excluded.content,
        tags = excluded.tags,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('handbook_chapters', r.chapterId),
        r.id,
        text(r.title) || '未命名知识点',
        text(r.summary),
        text(r.content),
        json(r.tags, []),
        intValue(r.order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeSimpleContent(records: JsonRecord[], table: 'banners' | 'faqs' | 'announcements') {
  for (const r of records) {
    if (table === 'banners') {
      await pool.query(
        `
        insert into public.banners (
          tenant_id, region_id, legacy_id, title, subtitle, content, button_text,
          button_link, bg_color, border_color, sort_order, is_active, created_at, updated_at
        )
        values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
          coalesce(nullif($13::text,'')::timestamptz, now()),
          coalesce(nullif($14::text,'')::timestamptz, now())
        )
        on conflict (tenant_id, legacy_id) do update set
          region_id = excluded.region_id,
          title = excluded.title,
          subtitle = excluded.subtitle,
          content = excluded.content,
          button_text = excluded.button_text,
          button_link = excluded.button_link,
          bg_color = excluded.bg_color,
          border_color = excluded.border_color,
          sort_order = excluded.sort_order,
          is_active = excluded.is_active,
          updated_at = excluded.updated_at
        `,
        [
          tenantId,
          await legacyId('regions', r.regionId),
          r.id,
          text(r.title),
          text(r.subtitle),
          text(r.content),
          text(r.buttonText),
          text(r.buttonLink),
          text(r.bgColor),
          text(r.borderColor),
          intValue(r.order),
          boolValue(r.isActive, true),
          dateText(r.created),
          dateText(r.updated),
        ],
      );
    } else if (table === 'faqs') {
      await pool.query(
        `
        insert into public.faqs (
          tenant_id, region_id, legacy_id, question, answer, sort_order,
          is_active, created_at, updated_at
        )
        values ($1,$2,$3,$4,$5,$6,$7,
          coalesce(nullif($8::text,'')::timestamptz, now()),
          coalesce(nullif($9::text,'')::timestamptz, now())
        )
        on conflict (tenant_id, legacy_id) do update set
          region_id = excluded.region_id,
          question = excluded.question,
          answer = excluded.answer,
          sort_order = excluded.sort_order,
          is_active = excluded.is_active,
          updated_at = excluded.updated_at
        `,
        [
          tenantId,
          await legacyId('regions', r.regionId),
          r.id,
          text(r.question),
          text(r.answer),
          intValue(r.order),
          boolValue(r.isActive, true),
          dateText(r.created),
          dateText(r.updated),
        ],
      );
    } else {
      await pool.query(
        `
        insert into public.announcements (
          tenant_id, legacy_id, content, link, bg_color, sort_order,
          is_active, created_at, updated_at
        )
        values ($1,$2,$3,$4,$5,$6,$7,
          coalesce(nullif($8::text,'')::timestamptz, now()),
          coalesce(nullif($9::text,'')::timestamptz, now())
        )
        on conflict (tenant_id, legacy_id) do update set
          content = excluded.content,
          link = excluded.link,
          bg_color = excluded.bg_color,
          sort_order = excluded.sort_order,
          is_active = excluded.is_active,
          updated_at = excluded.updated_at
        `,
        [
          tenantId,
          r.id,
          text(r.content),
          text(r.link),
          text(r.bgColor),
          intValue(r.order),
          boolValue(r.isActive, true),
          dateText(r.created),
          dateText(r.updated),
        ],
      );
    }
  }
}

async function normalizeProducts(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.products (
        tenant_id, region_id, legacy_id, title, price_label, link, type, tags,
        cover, preview_iframe, detail_images, sort_order, status, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active',
        coalesce(nullif($13::text,'')::timestamptz, now()),
        coalesce(nullif($14::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        title = excluded.title,
        price_label = excluded.price_label,
        link = excluded.link,
        type = excluded.type,
        tags = excluded.tags,
        cover = excluded.cover,
        preview_iframe = excluded.preview_iframe,
        detail_images = excluded.detail_images,
        sort_order = excluded.sort_order,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        r.id,
        text(r.title) || '未命名商品',
        text(r.price),
        text(r.link),
        text(r.type),
        json(r.tags, []),
        text(r.cover),
        text(r.previewIframe),
        json(r.detailImages, []),
        intValue(r.order),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeTimelines(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.timelines (
        tenant_id, region_id, school_id, legacy_id, type, title, description,
        event_date, link, sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,nullif($8::text,'')::date,$9,$10,$11,
        coalesce(nullif($12::text,'')::timestamptz, now()),
        coalesce(nullif($13::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        school_id = excluded.school_id,
        type = excluded.type,
        title = excluded.title,
        description = excluded.description,
        event_date = excluded.event_date,
        link = excluded.link,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        await legacyId('schools', r.schoolId),
        r.id,
        text(r.type),
        text(r.title) || '未命名时间线',
        text(r.description),
        dateText(r.eventDate),
        text(r.link),
        intValue(r.order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeExamDates(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.exam_dates (
        tenant_id, region_id, school_id, legacy_id, exam_name, exam_date,
        exam_type, sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,nullif($6::text,'')::date,$7,$8,$9,
        coalesce(nullif($10::text,'')::timestamptz, now()),
        coalesce(nullif($11::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        school_id = excluded.school_id,
        exam_name = excluded.exam_name,
        exam_date = excluded.exam_date,
        exam_type = excluded.exam_type,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        await legacyId('schools', r.schoolId),
        r.id,
        text(r.examName) || '考试日期',
        dateText(r.examDate),
        text(r.examType),
        intValue(r.order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeVideoExplanations(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.video_explanations (
        tenant_id, legacy_id, title, description, video_url, thumbnail_url,
        duration_seconds, knowledge_tags, is_general, subject_id, legacy_subject_id,
        difficulty, sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,
        coalesce(nullif($15::text,'')::timestamptz, now()),
        coalesce(nullif($16::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        title = excluded.title,
        description = excluded.description,
        video_url = excluded.video_url,
        thumbnail_url = excluded.thumbnail_url,
        duration_seconds = excluded.duration_seconds,
        knowledge_tags = excluded.knowledge_tags,
        is_general = excluded.is_general,
        subject_id = excluded.subject_id,
        legacy_subject_id = excluded.legacy_subject_id,
        difficulty = excluded.difficulty,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        text(r.title) || '未命名视频解析',
        text(r.description),
        text(r.videoUrl),
        text(r.thumbnailUrl),
        numberValue(r.duration, null),
        json(r.knowledgeTags, []),
        boolValue(r.isGeneral),
        await legacyId('subjects', r.subjectId),
        text(r.subjectId),
        numberValue(r.difficulty, null),
        intValue(r.order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeQuestionVideos(records: JsonRecord[]) {
  for (const r of records) {
    const questionId = await legacyId('questions', r.questionId);
    const videoId = await legacyId('video_explanations', r.videoId);
    if (!questionId || !videoId) continue;
    await pool.query(
      `
      insert into public.question_videos (
        tenant_id, question_id, video_id, legacy_id, legacy_question_id,
        legacy_video_id, video_type, sort_order, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        question_id = excluded.question_id,
        video_id = excluded.video_id,
        video_type = excluded.video_type,
        sort_order = excluded.sort_order,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        questionId,
        videoId,
        r.id,
        text(r.questionId),
        text(r.videoId),
        text(r.videoType) || 'specific',
        intValue(r.order),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
  await pool.query(
    `
    update public.questions q
    set has_video_explanation = true
    where tenant_id = $1 and exists (
      select 1 from public.question_videos qv
      where qv.tenant_id = q.tenant_id and qv.question_id = q.id
    )
    `,
    [tenantId],
  );
}

function normalizeReportType(value: unknown): string {
  const type = text(value)?.toLowerCase();
  if (!type) return 'other';
  if (['question_error', 'answer_error', 'explanation_bad', 'typo'].includes(type)) return 'question_error';
  if (type === 'content_error') return 'content_error';
  if (type === 'video_error') return 'video_error';
  if (type === 'asset_error') return 'asset_error';
  if (type === 'system_bug') return 'system_bug';
  if (type === 'suggestion') return 'suggestion';
  return 'other';
}

function normalizeReportStatus(value: unknown): string {
  const status = text(value)?.toLowerCase();
  if (['pending', 'accepted', 'rejected', 'resolved', 'closed'].includes(status || '')) return status || 'pending';
  return 'pending';
}

async function normalizeReports(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.reports (
        tenant_id, legacy_id, question_id, user_id, type, description,
        status, metadata, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        question_id = excluded.question_id,
        user_id = excluded.user_id,
        type = excluded.type,
        description = excluded.description,
        status = excluded.status,
        metadata = excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        await legacyId('questions', r.questionId),
        await userIdByLegacy(r.userId),
        normalizeReportType(r.type),
        text(r.description),
        normalizeReportStatus(r.status),
        JSON.stringify({
          source: 'pocketbase.reports',
          legacyType: text(r.type),
          legacyStatus: text(r.status),
        }),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeScorelineSchools(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.scoreline_schools (
        tenant_id, region_id, legacy_id, name, short_name, type, is_hot,
        sort_order, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        name = excluded.name,
        short_name = excluded.short_name,
        type = excluded.type,
        is_hot = excluded.is_hot,
        sort_order = excluded.sort_order,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        r.id,
        text(r.name) || '未命名分数线院校',
        text(r.shortName),
        text(r.type),
        boolValue(r.isHot),
        intValue(r.order),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeScorelineMajors(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.scoreline_majors (
        tenant_id, region_id, school_id, legacy_id, name, sort_order,
        has_restriction, restriction_desc, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        school_id = excluded.school_id,
        name = excluded.name,
        sort_order = excluded.sort_order,
        has_restriction = excluded.has_restriction,
        restriction_desc = excluded.restriction_desc,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        await legacyId('scoreline_schools', r.schoolId),
        r.id,
        text(r.name) || '未命名分数线专业',
        intValue(r.order),
        boolValue(r.hasRestriction),
        text(r.restrictionDesc),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeScorelineFields(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.scoreline_fields (
        tenant_id, region_id, legacy_id, field_key, field_name, field_type,
        unit, is_filter, is_required, is_visible, is_trend, options,
        placeholder, description, sort_order, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,
        coalesce(nullif($16::text,'')::timestamptz, now()),
        coalesce(nullif($17::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        field_key = excluded.field_key,
        field_name = excluded.field_name,
        field_type = excluded.field_type,
        unit = excluded.unit,
        is_filter = excluded.is_filter,
        is_required = excluded.is_required,
        is_visible = excluded.is_visible,
        is_trend = excluded.is_trend,
        options = excluded.options,
        placeholder = excluded.placeholder,
        description = excluded.description,
        sort_order = excluded.sort_order,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        r.id,
        text(r.fieldKey) || `field_${text(r.id)}`,
        text(r.fieldName) || '未命名字段',
        text(r.fieldType),
        text(r.unit),
        boolValue(r.isFilter),
        boolValue(r.isRequired),
        boolValue(r.isVisible, true),
        boolValue(r.isTrend),
        json(r.options, []),
        text(r.placeholder),
        text(r.description),
        intValue(r.sortOrder),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeScorelineRecords(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.scoreline_records (
        tenant_id, region_id, school_id, major_id, legacy_id, year,
        school_name, major_name, field_values, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,
        coalesce(nullif($10::text,'')::timestamptz, now()),
        coalesce(nullif($11::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        region_id = excluded.region_id,
        school_id = excluded.school_id,
        major_id = excluded.major_id,
        year = excluded.year,
        school_name = excluded.school_name,
        major_name = excluded.major_name,
        field_values = excluded.field_values,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('regions', r.regionId),
        await legacyId('scoreline_schools', r.schoolId),
        await legacyId('scoreline_majors', r.majorId),
        r.id,
        intValue(r.year),
        text(r.schoolName),
        text(r.majorName),
        json(r.fieldValues, {}),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeReferralTracks(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.referral_tracks (
        tenant_id, legacy_id, event_type, ref_code, ref_user_id,
        target_user_id, source, ip_address, user_agent, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,
        coalesce(nullif($10::text,'')::timestamptz, now()),
        coalesce(nullif($11::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        event_type = excluded.event_type,
        ref_code = excluded.ref_code,
        ref_user_id = excluded.ref_user_id,
        target_user_id = excluded.target_user_id,
        source = excluded.source,
        ip_address = excluded.ip_address,
        user_agent = excluded.user_agent,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        text(r.eventType) || 'unknown',
        text(r.refCode),
        await userIdByLegacy(r.refUserId),
        await userIdByLegacy(r.targetUserId),
        text(r.source),
        text(r.ip),
        text(r.userAgent),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

function refCodeFromScene(sceneValue: unknown, fallback: unknown): string {
  const scene = text(sceneValue);
  if (scene) {
    const match = /^ref[_=-]?(.+)$/i.exec(scene);
    const code = (match?.[1] || scene).replace(/[^a-z0-9]/gi, '').toUpperCase();
    if (code) return code;
  }
  const fallbackText = text(fallback)?.replace(/[^a-z0-9]/gi, '').toUpperCase();
  return fallbackText || 'LEGACY';
}

async function normalizeReferralQrcodes(runId: string, records: JsonRecord[]) {
  for (const r of records) {
    const userId = await userIdByLegacy(r.userId);
    const scene = text(r.scene) || `legacy_${text(r.id) || refCodeFromScene(null, r.userId)}`;
    const page = text(r.page) || 'pages/index/index';
    const refCode = refCodeFromScene(scene, r.id);
    const qrcodeUrl = text(r.qrcodeUrl) || text(r.image);
    const metadata = {
      source: 'pocketbase.referral_qrcodes',
      legacyId: text(r.id),
      legacyUserId: text(r.userId),
      image: text(r.image),
      originalQrcodeUrl: text(r.qrcodeUrl),
    };

    if (!userId) {
      await issue(
        runId,
        'referral_qrcodes',
        r.id,
        'referral_qrcode_user_not_found',
        `Referral qrcode cannot be attached to a user because userId was not resolved: ${text(r.userId) || '(empty)'}`,
        'critical',
      );
      continue;
    }

    await pool.query(
      `
      insert into public.referral_codes (tenant_id, user_id, code, status, channel, landing_path, metadata, created_at, updated_at)
      values ($1,$2,$3::citext,'active','wechat-miniapp',$4,$5::jsonb,
        coalesce(nullif($6::text,'')::timestamptz, now()),
        coalesce(nullif($7::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, user_id) do update set
        code = excluded.code,
        channel = excluded.channel,
        landing_path = excluded.landing_path,
        metadata = public.referral_codes.metadata || excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [tenantId, userId, refCode, page, JSON.stringify(metadata), dateText(r.created), dateText(r.updated)],
    );

    await pool.query(
      `
      insert into public.referral_qrcodes (
        tenant_id, user_id, ref_code, scene, page, provider, qrcode_url, status,
        metadata, created_at, updated_at
      )
      values ($1,$2,$3::citext,$4,$5,'wechat-miniapp',$6,$7,$8::jsonb,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, provider, scene, page) do update set
        user_id = excluded.user_id,
        ref_code = excluded.ref_code,
        qrcode_url = excluded.qrcode_url,
        status = excluded.status,
        metadata = public.referral_qrcodes.metadata || excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        userId,
        refCode,
        scene,
        page,
        qrcodeUrl,
        qrcodeUrl ? 'ready' : 'pending',
        JSON.stringify(metadata),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeCommissionSettings(records: JsonRecord[]) {
  for (const r of records) {
    const config = {
      source: 'pocketbase.commission_settings',
      legacyId: text(r.id),
      remark: text(r.remark),
      raw: sanitizeRecord(r),
    };

    await pool.query(
      `
      insert into public.tenant_commission_settings (
        tenant_id, default_rate, min_settlement_cents, settlement_cycle, config,
        created_at, updated_at
      )
      values ($1,$2,0,'monthly',$3::jsonb,
        coalesce(nullif($4::text,'')::timestamptz, now()),
        coalesce(nullif($5::text,'')::timestamptz, now())
      )
      on conflict (tenant_id) do update set
        default_rate = excluded.default_rate,
        config = public.tenant_commission_settings.config || excluded.config,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        clampRate(r.defaultRate, 0.2),
        JSON.stringify(config),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeBadges(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.badges (
        tenant_id, legacy_id, name, description, category, icon_url, level,
        unlock_type, condition_field, condition_operator, condition_value,
        condition_extra, sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,
        coalesce(nullif($15::text,'')::timestamptz, now()),
        coalesce(nullif($16::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        name = excluded.name,
        description = excluded.description,
        category = excluded.category,
        icon_url = excluded.icon_url,
        level = excluded.level,
        unlock_type = excluded.unlock_type,
        condition_field = excluded.condition_field,
        condition_operator = excluded.condition_operator,
        condition_value = excluded.condition_value,
        condition_extra = excluded.condition_extra,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        r.id,
        text(r.name) || '未命名徽章',
        text(r.description),
        text(r.category),
        text(r.icon_url),
        numberValue(r.level, null),
        text(r.unlock_type),
        text(r.condition_field),
        text(r.condition_operator),
        numberValue(r.condition_value, null),
        json(r.condition_extra, {}),
        intValue(r.sort_order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeUserBadges(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.user_badges (
        tenant_id, user_id, badge_id, granted_by, legacy_id, note,
        granted_at, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz,
        coalesce(nullif($8::text,'')::timestamptz, now()),
        coalesce(nullif($9::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        user_id = excluded.user_id,
        badge_id = excluded.badge_id,
        granted_by = excluded.granted_by,
        note = excluded.note,
        granted_at = excluded.granted_at,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await userIdByLegacy(r.user),
        await legacyId('badges', r.badge),
        await userIdByLegacy(r.granted_by),
        r.id,
        text(r.note),
        dateText(r.granted_at),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeWordUserData(progressRecords: JsonRecord[], favoriteRecords: JsonRecord[]) {
  for (const r of progressRecords) {
    await pool.query(
      `
      insert into public.user_word_progress (
        tenant_id, user_id, word_id, legacy_id, legacy_user_id, legacy_word_id,
        status, correct_count, wrong_count, last_review_date, next_review_date,
        created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,nullif($11::text,'')::timestamptz,
        coalesce(nullif($12::text,'')::timestamptz, now()),
        coalesce(nullif($13::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        user_id = excluded.user_id,
        word_id = excluded.word_id,
        status = excluded.status,
        correct_count = excluded.correct_count,
        wrong_count = excluded.wrong_count,
        last_review_date = excluded.last_review_date,
        next_review_date = excluded.next_review_date,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await userIdByLegacy(r.userId),
        await legacyId('vocabulary_words', r.wordId),
        r.id,
        text(r.userId),
        text(r.wordId),
        ['new', 'learning', 'mastered', 'reviewing'].includes(text(r.status) || '') ? text(r.status) : 'new',
        intValue(r.correctCount),
        intValue(r.wrongCount),
        dateText(r.lastReviewDate),
        dateText(r.nextReviewDate),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }

  for (const r of favoriteRecords) {
    await pool.query(
      `
      insert into public.user_word_favorites (
        tenant_id, user_id, word_id, legacy_id, legacy_user_id, legacy_word_id,
        note, favorited_at, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,nullif($8::text,'')::timestamptz,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        user_id = excluded.user_id,
        word_id = excluded.word_id,
        note = excluded.note,
        favorited_at = excluded.favorited_at,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await userIdByLegacy(r.userId),
        await legacyId('vocabulary_words', r.wordId),
        r.id,
        text(r.userId),
        text(r.wordId),
        text(r.note),
        dateText(r.createdAt),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeRecentPractices(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.recent_practices (
        tenant_id, user_id, legacy_id, practice_type, target_legacy_id,
        target_name, progress, color, last_access_at, last_practice_at,
        metadata, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,nullif($10::text,'')::timestamptz,$11,
        coalesce(nullif($12::text,'')::timestamptz, now()),
        coalesce(nullif($13::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        user_id = excluded.user_id,
        practice_type = excluded.practice_type,
        target_legacy_id = excluded.target_legacy_id,
        target_name = excluded.target_name,
        progress = excluded.progress,
        color = excluded.color,
        last_access_at = excluded.last_access_at,
        last_practice_at = excluded.last_practice_at,
        metadata = excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await userIdByLegacy(r.userId),
        r.id,
        text(r.type),
        text(r.targetId),
        text(r.targetName),
        intValue(r.progress),
        text(r.color),
        dateText(r.lastAccessTime),
        dateText(r.lastPracticeAt),
        json({ legacyTargetId: text(r.targetId) }, {}),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeAuxiliary(records: JsonRecord[], collection: string) {
  for (const r of records) {
    if (collection === 'audit_logs') {
      await pool.query(
        `
        insert into public.audit_logs (
          tenant_id, actor_user_id, action, target_type, target_id,
          details, ip_address, user_agent, created_at
        )
        values ($1,$2,$3,$4,$5,$6,$7,$8,coalesce(nullif($9::text,'')::timestamptz, now()))
        `,
        [
          tenantId,
          await userIdByLegacy(r.userId),
          text(r.action) || 'legacy_audit',
          text(r.targetType),
          text(r.targetId),
          json({ detail: text(r.detail), metadata: text(r.metadata) }, {}),
          text(r.ip),
          text(r.userAgent),
          dateText(r.created),
        ],
      );
    }

    if (collection === 'crm_config') {
      await pool.query(
        `
        insert into public.crm_config (
          tenant_id, enabled, url, secret_ref, form_name, exam_type,
          timeout_sec, delay_sec, created_at, updated_at
        )
        values ($1,$2,$3,$4,$5,$6,$7,$8,
          coalesce(nullif($9::text,'')::timestamptz, now()),
          coalesce(nullif($10::text,'')::timestamptz, now())
        )
        on conflict (tenant_id) do update set
          enabled = excluded.enabled,
          url = excluded.url,
          secret_ref = excluded.secret_ref,
          form_name = excluded.form_name,
          exam_type = excluded.exam_type,
          timeout_sec = excluded.timeout_sec,
          delay_sec = excluded.delay_sec,
          updated_at = excluded.updated_at
        `,
        [
          tenantId,
          boolValue(r.enabled),
          text(r.url),
          text(r.secret) ? 'app_private.tenant_secrets:crm:legacy_crm_secret' : null,
          text(r.formName),
          text(r.examType),
          numberValue(r.timeoutSec, null),
          numberValue(r.delaySec, null),
          dateText(r.created),
          dateText(r.updated),
        ],
      );
      await upsertSecret('crm', 'legacy_crm_secret', r.secret, 'legacy_crm');
    }

    if (collection === 'crm_webhook_queue') {
      await pool.query(
        `
        insert into public.crm_webhook_queue (
          tenant_id, legacy_id, record_id, status, scheduled_at, attempts,
          next_attempt_at, last_error, last_http_code, lead_id, sent_at,
          created_at, updated_at
        )
        values ($1,$2,$3,$4,nullif($5::text,'')::timestamptz,$6,nullif($7::text,'')::timestamptz,$8,$9,$10,nullif($11::text,'')::timestamptz,
          coalesce(nullif($12::text,'')::timestamptz, now()),
          coalesce(nullif($13::text,'')::timestamptz, now())
        )
        on conflict (tenant_id, legacy_id) do update set
          record_id = excluded.record_id,
          status = excluded.status,
          scheduled_at = excluded.scheduled_at,
          attempts = excluded.attempts,
          next_attempt_at = excluded.next_attempt_at,
          last_error = excluded.last_error,
          last_http_code = excluded.last_http_code,
          lead_id = excluded.lead_id,
          sent_at = excluded.sent_at,
          updated_at = excluded.updated_at
        `,
        [
          tenantId,
          r.id,
          text(r.recordId),
          text(r.status) || 'pending',
          dateText(r.scheduledAt),
          intValue(r.attempts),
          dateText(r.nextAttemptAt),
          text(r.lastError),
          numberValue(r.lastHttpCode, null),
          text(r.leadId),
          dateText(r.sentAt),
          dateText(r.created),
          dateText(r.updated),
        ],
      );
    }

    if (collection === 'crm_webhook_log') {
      await pool.query(
        `
        insert into public.crm_webhook_log (
          tenant_id, legacy_id, record_id, http_code, outcome, error_message,
          lead_id, request_body, response_summary, signed_at, attempt, created_at
        )
        values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,$11,
          coalesce(nullif($12::text,'')::timestamptz, now())
        )
        on conflict (tenant_id, legacy_id) do update set
          record_id = excluded.record_id,
          http_code = excluded.http_code,
          outcome = excluded.outcome,
          error_message = excluded.error_message,
          lead_id = excluded.lead_id,
          request_body = excluded.request_body,
          response_summary = excluded.response_summary,
          signed_at = excluded.signed_at,
          attempt = excluded.attempt
        `,
        [
          tenantId,
          r.id,
          text(r.recordId),
          numberValue(r.httpCode, null),
          text(r.outcome),
          text(r.errorMessage),
          text(r.leadId),
          text(r.requestBody),
          text(r.responseSummary),
          dateText(r.signedAt),
          numberValue(r.attempt, null),
          dateText(r.created),
        ],
      );
    }
  }
}

async function normalizeContentAssets(records: JsonRecord[], collection: 'app_assets' | 'images') {
  for (const r of records) {
    await pool.query(
      `
      insert into public.content_assets (
        tenant_id, legacy_id, asset_key, title, category, description,
        file_name, cdn_url, is_public, metadata, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,
        coalesce(nullif($11::text,'')::timestamptz, now()),
        coalesce(nullif($12::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        asset_key = excluded.asset_key,
        title = excluded.title,
        category = excluded.category,
        description = excluded.description,
        file_name = excluded.file_name,
        cdn_url = excluded.cdn_url,
        is_public = excluded.is_public,
        metadata = excluded.metadata,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        `${collection}:${text(r.id)}`,
        text(r.key),
        text(r.title),
        text(r.category),
        text(r.desc),
        text(r.image),
        text(r.cdnUrl),
        boolValue(r.isPublic),
        json({ sourceCollection: collection }, {}),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeDailyStats(records: JsonRecord[], collection: 'dashboard_daily_stats' | 'revenue_daily_stats') {
  for (const r of records) {
    if (collection === 'dashboard_daily_stats') {
      await pool.query(
        `
        insert into public.dashboard_daily_stats (
          tenant_id, legacy_id, stat_date, region_id, legacy_region_id,
          new_users, new_questions, new_orders, new_revenue_cents,
          active_users, rebuilt_at
        )
        values ($1,$2,nullif($3::text,'')::date,$4,$5,$6,$7,$8,$9,$10,nullif($11::text,'')::timestamptz)
        on conflict (tenant_id, stat_date, legacy_region_id) do update set
          new_users = excluded.new_users,
          new_questions = excluded.new_questions,
          new_orders = excluded.new_orders,
          new_revenue_cents = excluded.new_revenue_cents,
          active_users = excluded.active_users,
          rebuilt_at = excluded.rebuilt_at
        `,
        [
          tenantId,
          r.id,
          dateText(r.statDate),
          await legacyId('regions', r.regionId),
          text(r.regionId),
          intValue(r.newUsers),
          intValue(r.newQuestions),
          intValue(r.newOrders),
          cents(r.newRevenue),
          intValue(r.activeUsers),
          dateText(r.rebuiltAt),
        ],
      );
    } else {
      await pool.query(
        `
        insert into public.revenue_daily_stats (
          tenant_id, legacy_id, stat_date, region_id, legacy_region_id,
          sale_type, real_revenue_cents, order_count, code_count, code_used,
          code_estimated_cents, estimated_revenue_cents, rebuilt_at
        )
        values ($1,$2,nullif($3::text,'')::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,nullif($13::text,'')::timestamptz)
        on conflict (tenant_id, stat_date, legacy_region_id, sale_type) do update set
          real_revenue_cents = excluded.real_revenue_cents,
          order_count = excluded.order_count,
          code_count = excluded.code_count,
          code_used = excluded.code_used,
          code_estimated_cents = excluded.code_estimated_cents,
          estimated_revenue_cents = excluded.estimated_revenue_cents,
          rebuilt_at = excluded.rebuilt_at
        `,
        [
          tenantId,
          r.id,
          dateText(r.statDate),
          await legacyId('regions', r.regionId),
          text(r.regionId),
          text(r.saleType) || '',
          cents(r.realRevenue),
          intValue(r.orderCount),
          intValue(r.codeCount),
          intValue(r.codeUsed),
          cents(r.codeEstimated),
          cents(r.estimatedRevenue),
          dateText(r.rebuiltAt),
        ],
      );
    }
  }
}

async function normalizeQuestionTypeGroups(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.question_type_groups (
        tenant_id, subject_id, legacy_id, legacy_subject_id, display_name,
        types, sort_order, is_active, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,$7,$8,
        coalesce(nullif($9::text,'')::timestamptz, now()),
        coalesce(nullif($10::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        subject_id = excluded.subject_id,
        legacy_subject_id = excluded.legacy_subject_id,
        display_name = excluded.display_name,
        types = excluded.types,
        sort_order = excluded.sort_order,
        is_active = excluded.is_active,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('subjects', r.subjectId),
        r.id,
        text(r.subjectId),
        text(r.displayName) || '题型分组',
        json(r.types, []),
        intValue(r.order),
        boolValue(r.isActive, true),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function normalizeSubjectShares(records: JsonRecord[]) {
  for (const r of records) {
    await pool.query(
      `
      insert into public.subject_shares (
        tenant_id, source_subject_id, target_subject_id, legacy_id,
        legacy_source_subject_id, legacy_target_subject_id, created_at, updated_at
      )
      values ($1,$2,$3,$4,$5,$6,
        coalesce(nullif($7::text,'')::timestamptz, now()),
        coalesce(nullif($8::text,'')::timestamptz, now())
      )
      on conflict (tenant_id, legacy_id) do update set
        source_subject_id = excluded.source_subject_id,
        target_subject_id = excluded.target_subject_id,
        legacy_source_subject_id = excluded.legacy_source_subject_id,
        legacy_target_subject_id = excluded.legacy_target_subject_id,
        updated_at = excluded.updated_at
      `,
      [
        tenantId,
        await legacyId('subjects', r.sourceSubjectId),
        await legacyId('subjects', r.targetSubjectId),
        r.id,
        text(r.sourceSubjectId),
        text(r.targetSubjectId),
        dateText(r.created),
        dateText(r.updated),
      ],
    );
  }
}

async function runNormalizer(
  runId: string,
  collections: CollectionMap,
  collection: string,
  normalizer: (records: JsonRecord[]) => Promise<void>,
) {
  const records = collections[collection] || [];
  if (records.length === 0) return;
  await normalizer(records);
  await markNormalized(runId, collection);
  console.log(`${collection}: ${records.length} records normalized`);
}

async function loadCollections(): Promise<CollectionMap> {
  const files = fs.readdirSync(exportDir).filter(file => file.toLowerCase().endsWith('.json'));
  const collections: CollectionMap = {};
  for (const file of files) {
    if (nonCollectionJsonFiles.has(file)) continue;
    const collection = collectionNameFromFile(file);
    collections[collection] = asArray(JSON.parse(fs.readFileSync(path.join(exportDir, file), 'utf8')));
  }
  return collections;
}

async function normalizeAll(runId: string, collections: CollectionMap) {
  await runNormalizer(runId, collections, 'regions', normalizeRegions);
  await runNormalizer(runId, collections, 'region_modules', normalizeRegionModules);
  await runNormalizer(runId, collections, 'module_nodes', normalizeModuleNodes);
  await runNormalizer(runId, collections, 'schools', normalizeSchools);
  await runNormalizer(runId, collections, 'majors', normalizeMajors);
  await runNormalizer(runId, collections, 'subjects', normalizeSubjects);
  await runNormalizer(runId, collections, 'categories', normalizeCategories);
  await runNormalizer(runId, collections, 'question_type_groups', normalizeQuestionTypeGroups);
  await runNormalizer(runId, collections, 'subject_shares', normalizeSubjectShares);
  await runNormalizer(runId, collections, 'questions', normalizeQuestions);
  if ((collections.questions || []).length > 0) await buildPracticeNavigation(runId);

  await runNormalizer(runId, collections, 'users', normalizeUsers);
  await normalizeUserEntitlementsAndStats(runId, collections.users || []);
  if ((collections.users || []).length > 0) await markNormalized(runId, 'users');
  await runNormalizer(runId, collections, 'user_answer_records', records => normalizeUserAnswerRecords(runId, records));

  await runNormalizer(runId, collections, 'settings', normalizeSettings);
  await runNormalizer(runId, collections, 'svip_plans', normalizeSvipPlans);
  await runNormalizer(runId, collections, 'mock_exam_configs', records => normalizeMockExamConfigs(runId, records));
  await runNormalizer(runId, collections, 'code_batches', normalizeCodeBatches);
  await runNormalizer(runId, collections, 'coupons', normalizeCoupons);
  await runNormalizer(runId, collections, 'coupon_redemptions', normalizeCouponRedemptions);
  await runNormalizer(runId, collections, 'codes', normalizeActivationCodes);
  await runNormalizer(runId, collections, 'orders', records => normalizeOrders(runId, records));

  await runNormalizer(runId, collections, 'vocabulary_units', normalizeVocabularyUnits);
  await runNormalizer(runId, collections, 'vocabulary', normalizeVocabularyWords);
  await normalizeWordUserData(collections.user_word_progress || [], collections.user_word_favorites || []);
  if ((collections.user_word_progress || []).length > 0) {
    await markNormalized(runId, 'user_word_progress');
    console.log(`user_word_progress: ${collections.user_word_progress.length} records normalized`);
  }
  if ((collections.user_word_favorites || []).length > 0) {
    await markNormalized(runId, 'user_word_favorites');
    console.log(`user_word_favorites: ${collections.user_word_favorites.length} records normalized`);
  }

  await runNormalizer(runId, collections, 'handbook_subjects', normalizeHandbookSubjects);
  await runNormalizer(runId, collections, 'handbook_chapters', records => normalizeHandbookChapters(runId, records));
  await runNormalizer(runId, collections, 'handbook_entries', normalizeHandbookEntries);
  await runNormalizer(runId, collections, 'banners', records => normalizeSimpleContent(records, 'banners'));
  await runNormalizer(runId, collections, 'faqs', records => normalizeSimpleContent(records, 'faqs'));
  await runNormalizer(runId, collections, 'announcements', records => normalizeSimpleContent(records, 'announcements'));
  await runNormalizer(runId, collections, 'products', normalizeProducts);
  await runNormalizer(runId, collections, 'timelines', normalizeTimelines);
  await runNormalizer(runId, collections, 'exam_dates', normalizeExamDates);
  await runNormalizer(runId, collections, 'reports', normalizeReports);
  await runNormalizer(runId, collections, 'video_explanations', normalizeVideoExplanations);
  await runNormalizer(runId, collections, 'question_videos', normalizeQuestionVideos);

  await runNormalizer(runId, collections, 'scoreline_schools', normalizeScorelineSchools);
  await runNormalizer(runId, collections, 'scoreline_majors', normalizeScorelineMajors);
  await runNormalizer(runId, collections, 'scoreline_fields', normalizeScorelineFields);
  await runNormalizer(runId, collections, 'scoreline_records', normalizeScorelineRecords);
  await runNormalizer(runId, collections, 'referral_tracks', normalizeReferralTracks);
  await runNormalizer(runId, collections, 'referral_qrcodes', records => normalizeReferralQrcodes(runId, records));
  await runNormalizer(runId, collections, 'commission_settings', normalizeCommissionSettings);
  await runNormalizer(runId, collections, 'badges', normalizeBadges);
  await runNormalizer(runId, collections, 'user_badges', normalizeUserBadges);
  await runNormalizer(runId, collections, 'recent_practices', normalizeRecentPractices);
  await runNormalizer(runId, collections, 'audit_logs', records => normalizeAuxiliary(records, 'audit_logs'));
  await runNormalizer(runId, collections, 'crm_config', records => normalizeAuxiliary(records, 'crm_config'));
  await runNormalizer(runId, collections, 'crm_webhook_queue', records => normalizeAuxiliary(records, 'crm_webhook_queue'));
  await runNormalizer(runId, collections, 'crm_webhook_log', records => normalizeAuxiliary(records, 'crm_webhook_log'));
  await runNormalizer(runId, collections, 'app_assets', records => normalizeContentAssets(records, 'app_assets'));
  await runNormalizer(runId, collections, 'images', records => normalizeContentAssets(records, 'images'));
  await runNormalizer(runId, collections, 'dashboard_daily_stats', records => normalizeDailyStats(records, 'dashboard_daily_stats'));
  await runNormalizer(runId, collections, 'revenue_daily_stats', records => normalizeDailyStats(records, 'revenue_daily_stats'));
}

async function main() {
  if (!fs.existsSync(exportDir)) {
    throw new Error(`Export dir not found: ${exportDir}. Put collection JSON files there, e.g. users.json and questions.json.`);
  }

  await ensureTenant();
  const runId = await createRun();
  const collections = await loadCollections();
  const stats: Record<string, number> = {};

  for (const [collection, records] of Object.entries(collections)) {
    stats[collection] = await importRaw(runId, collection, records);
    console.log(`${collection}: ${stats[collection]} raw records imported`);
  }

  await normalizeAll(runId, collections);

  await pool.query(
    `update public.pb_import_runs set status = 'completed', stats = $2, finished_at = now() where id = $1`,
    [runId, JSON.stringify(stats)],
  );

  console.log(`Import completed. runId=${runId}`);
}

main()
  .catch(async error => {
    console.error(error);
    process.exitCode = 1;
  })
  .finally(async () => {
    await closeDb();
  });
