import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { apiRequest } from "@/shared/api/request";

export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
export const DEFAULT_POOL_ID = "default";
const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100;
const GIFT_DIAMOND_RATIOS_PATH = "/v1/admin/operations/gift-diamond-ratios";

export async function fetchOpsDashboard(query = {}) {
    // Ops Center 是跨 App 的运营面板：先拉应用主数据，再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
    // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤，没有"全部应用"一次拉取的入口。
    const appsPayload = await opsRequest("/apps", { query });
    const apps = normalizeItems(appsPayload);
    const appCodes = uniqueAppCodes(apps);

    const perApp = await Promise.all(
        appCodes.map(async (appCode) => {
            const [luckyGifts, poolBalances, drawSummary, experiments, giftHostDiamondRatios] = await Promise.all([
                fetchAppLuckyGiftConfigs(appCode),
                opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
                opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
                // 实验列表驱动配置行的实验徽章和发布禁用；该接口比面板后上线，请求失败时降级为空列表
                // 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝，降级是安全的。
                fetchLuckyGiftExperiments(appCode).catch(() => []),
                // 主播钻石是 wallet 的应用级礼物配置，不属于幸运礼物规则。读取失败时保留配置列表可查看，
                // 但把 null 传到编辑弹窗并禁止发布，绝不能把返还金币比例或前端默认值当成主播钻石。
                fetchGiftHostDiamondRatios(appCode).catch(() => null),
            ]);
            return { appCode, drawSummary, experiments, giftHostDiamondRatios, luckyGifts, poolBalances };
        }),
    );

    return normalizeDashboard({ apps, perApp });
}

export async function fetchGiftHostDiamondRatios(appCode) {
    const normalizedAppCode = normalizeAppCodeValue(appCode);
    const payload = await apiRequest(GIFT_DIAMOND_RATIOS_PATH, {
        // Ops Center 跨应用扇出读取，不能使用主后台当前选中的 X-App-Code；每个请求必须显式绑定
        // 当前行应用，否则 lalu 的主播钻石比例会被错误复用到其他应用。
        headers: { "X-App-Code": normalizedAppCode },
        method: "GET",
        query: { region_id: 0 },
    });
    return Object.fromEntries(
        normalizeItems(payload)
            .map((item) => [
                String(item.giftTypeCode ?? item.gift_type_code ?? "")
                    .trim()
                    .toLowerCase(),
                finitePercentOrNull(item.ratioPercent ?? item.ratio_percent),
            ])
            .filter(([giftType, ratio]) => giftType && ratio !== null),
    );
}

async function fetchAppLuckyGiftConfigs(appCode) {
    // 必须用复数 /configs：它返回该应用每个奖池的最新已发布规则版本。
    // 单数 /config 只回默认奖池（无配置时是服务端草稿），会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。
    const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } }))
        .map((config) => normalizeLuckyGiftConfig(config, appCode))
        .filter((config) => config.app_code);
    if (published.length) {
        return published;
    }
    // 一个已发布配置都没有的应用仍要展示一行可编辑草稿，让运营从这里发布第一版；
    // 草稿的默认参数（RTP、回收率、结算窗口）以服务端 DefaultRuleConfig 为准，不在前端伪造。
    const draft = normalizeLuckyGiftConfig(
        await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }),
        appCode,
    );
    return draft.app_code ? [draft] : [];
}

export async function fetchLuckyGiftDraws({
    appCode,
    page = 1,
    pageSize = 20,
    poolId = "",
    status = "",
    userId = "",
} = {}) {
    const query = {
        app_code: appCode,
        page,
        page_size: pageSize,
        pool_id: poolId,
        status,
    };
    // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID：纯数字走 user_id 精确匹配，
    // 其余走 external_user_id，避免运营要先分辨用户来源再选字段。
    const trimmedUserID = String(userId || "").trim();
    if (/^\d+$/.test(trimmedUserID)) {
        query.user_id = trimmedUserID;
    } else if (trimmedUserID) {
        query.external_user_id = trimmedUserID;
    }

    const payload = await opsRequest("/lucky-gifts/draws", { query });
    return {
        items: normalizeItems(payload),
        page: numberValue(payload.page) || page,
        pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize,
        total: numberValue(payload.total),
    };
}

export async function fetchLuckyGiftUserProfiles({
    appCode,
    cursor = "",
    identityType = "all",
    keyword = "",
    page = 1,
    pageSize = 20,
    poolId,
    sortBy = "last_draw_at",
    sortDirection = "desc",
    stage = "",
    status = "",
} = {}) {
    const operationPath = apiEndpointPath(API_OPERATIONS.listLuckyGiftUserProfiles);
    const payload = await opsRequest(opsRelativePath(operationPath), {
        query: {
            app_code: String(appCode || "")
                .trim()
                .toLowerCase(),
            cursor: String(cursor || "").trim(),
            identity_type: normalizeIdentityType(identityType),
            page,
            page_size: pageSize,
            pool_id: String(poolId || "").trim(),
            sort_by: String(sortBy || "last_draw_at").trim(),
            sort_direction: String(sortDirection || "desc").trim(),
            stage: String(stage || "").trim(),
            status: String(status || "").trim(),
            user_keyword: String(keyword || "").trim(),
        },
    });
    const snapshotAtMs = numberValue(payload.snapshot_at_ms ?? payload.snapshotAtMs);
    return {
        items: normalizeProfileItems(payload).map((item) => normalizeLuckyGiftUserProfile(item, snapshotAtMs)),
        page: numberValue(payload.page) || page,
        pageSize: numberValue(payload.page_size ?? payload.pageSize) || pageSize,
        nextCursor: String(payload.next_cursor ?? payload.nextCursor ?? "").trim(),
        hasMore: Boolean(payload.has_more ?? payload.hasMore),
        snapshotAtMs,
        total: numberValue(payload.total),
    };
}

export async function fetchLuckyGiftUserProfile({
    appCode,
    identityType,
    externalUserId,
    jackpotCursor = "",
    jackpotPage = 1,
    jackpotPageSize = 20,
    poolId,
    userId,
} = {}) {
    const normalizedIdentityType = normalizeIdentityType(identityType);
    const operationPath = apiEndpointPath(API_OPERATIONS.getLuckyGiftUserProfile);
    const payload = await opsRequest(opsRelativePath(operationPath), {
        query: {
            app_code: String(appCode || "")
                .trim()
                .toLowerCase(),
            // 外部稳定用户键仅用于 owner 内部关联，前端查询和展示始终使用接入方原始 external_user_id。
            external_user_id: normalizedIdentityType === "external" ? String(externalUserId || "").trim() : "",
            identity_type: normalizedIdentityType,
            internal_user_id: normalizedIdentityType === "internal" ? String(userId || "").trim() : "",
            jackpot_page: jackpotPage,
            jackpot_page_size: jackpotPageSize,
            jackpot_cursor: String(jackpotCursor || "").trim(),
            pool_id: String(poolId || "").trim(),
        },
    });
    const profileSource = payload.profile || payload.item || payload;
    return {
        jackpotHits: normalizeItems(payload.jackpot_hits ?? payload.jackpotHits).map(normalizeLuckyGiftJackpotHit),
        jackpotHitTotal: numberValue(payload.jackpot_hit_total ?? payload.jackpotHitTotal),
        jackpotPage: numberValue(payload.jackpot_page ?? payload.jackpotPage) || jackpotPage,
        jackpotPageSize: numberValue(payload.jackpot_page_size ?? payload.jackpotPageSize) || jackpotPageSize,
        nextJackpotCursor: String(payload.next_jackpot_cursor ?? payload.nextJackpotCursor ?? "").trim(),
        jackpotHasMore: Boolean(payload.jackpot_has_more ?? payload.jackpotHasMore),
        multiplierDistribution: aggregateLuckyGiftMultiplierMetrics(
            normalizeItems(
                payload.multiplier_distribution ?? payload.multiplierDistribution ?? payload.multiplier_stats,
            ),
        ),
        profile: normalizeLuckyGiftUserProfile(
            profileSource,
            payload.snapshot_at_ms ??
                payload.snapshotAtMs ??
                profileSource.snapshot_at_ms ??
                profileSource.snapshotAtMs,
        ),
    };
}

export async function saveLuckyGiftConfig(config = {}) {
    const payload = luckyGiftConfigPayload(config);
    return opsRequest("/lucky-gifts/config", {
        body: payload,
        method: "PUT",
        query: {
            app_code: payload.app_code,
            pool_id: payload.pool_id,
        },
    });
}

export async function fetchLuckyGiftConfigVersions(appCode, poolId) {
    const normalizedAppCode = normalizeAppCodeValue(appCode);
    const payload = await opsRequest("/lucky-gifts/config/versions", {
        query: {
            app_code: normalizedAppCode,
            pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
        },
    });
    // 历史版本必须展示 owner 保存的不可变快照，不能套用当前礼物分成或表单默认值，
    // 否则运营看到的目标版本与实际回滚内容会不一致。
    return normalizeItems(payload).map((config) => normalizeLuckyGiftConfig(config, normalizedAppCode));
}

export async function rollbackLuckyGiftConfig({ appCode, poolId, targetRuleVersion } = {}) {
    const normalizedAppCode = normalizeAppCodeValue(appCode);
    const payload = await opsRequest("/lucky-gifts/config/rollback", {
        body: { target_rule_version: numberValue(targetRuleVersion) },
        method: "POST",
        query: {
            app_code: normalizedAppCode,
            pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
        },
    });
    return {
        sourceRuleVersion: numberValue(payload?.source_rule_version ?? payload?.sourceRuleVersion),
        config: normalizeLuckyGiftConfig(payload?.config || {}, normalizedAppCode),
    };
}

export async function adjustLuckyGiftPool({
    adjustmentId,
    amountCoins,
    appCode,
    direction,
    poolId,
    reason,
    strategyVersion,
} = {}) {
    if (direction !== "in" && direction !== "out") {
        throw new Error("奖池水位调整方向不正确");
    }
    const operation = direction === "out" ? API_OPERATIONS.debitLuckyGiftPool : API_OPERATIONS.creditLuckyGiftPool;
    const operationPath = apiEndpointPath(operation);

    // adjustment_id 是 owner 侧资金变更的幂等键，同一个弹窗失败重试必须原样复用；request_id 只做链路追踪，
    // 不能替代业务幂等。方向映射到两条独立路由，使前后端权限也能按补水、扣水分别收敛。
    const payload = await opsRequest(opsRelativePath(operationPath), {
        body: {
            adjustment_id: String(adjustmentId || "").trim(),
            amount_coins: numberValue(amountCoins),
            reason: String(reason || "").trim(),
        },
        method: "POST",
        query: {
            app_code: String(appCode || "")
                .trim()
                .toLowerCase(),
            pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
            strategy_version: normalizeStrategyVersion(strategyVersion),
        },
    });
    return normalizePoolAdjustmentResult(payload);
}

export async function fetchLuckyGiftExperiments(appCode, { poolId = "", status = "", withStats = false } = {}) {
    const normalizedAppCode = normalizeAppCodeValue(appCode);
    const payload = await opsRequest("/lucky-gifts/experiments", {
        query: {
            app_code: normalizedAppCode,
            pool_id: String(poolId || "").trim(),
            status: String(status || "").trim(),
            // with_stats=1 让 owner 按 control/treatment 聚合抽奖统计，代价较高；
            // dashboard 扇出保持轻量不带该参数，只有实验管理弹窗需要统计。
            with_stats: withStats ? 1 : "",
        },
    });
    return normalizeItems(payload).map((item) => normalizeLuckyGiftExperiment(item, normalizedAppCode));
}

export async function createLuckyGiftExperiment(appCode, request = {}) {
    const config = luckyGiftConfigPayload(request.config || {});
    const normalizedAppCode = normalizeAppCodeValue(appCode || config.app_code);
    // 实验组配置由 owner 原子发布为新规则版本并立即参与开奖，enabled 必须为 true；
    // 表单层已强制启用，这里再收敛一次，防止其他调用方把停用草稿提交成实验组。
    const payload = await opsRequest("/lucky-gifts/experiments", {
        body: {
            config: { ...config, enabled: true },
            name: String(request.name || "").trim(),
            overrides: normalizeExperimentOverrides(request.overrides),
            treatment_traffic_percent: numberValue(request.treatment_traffic_percent),
        },
        method: "POST",
        query: { app_code: normalizedAppCode },
    });
    return {
        ...payload,
        experiment: normalizeLuckyGiftExperiment(payload?.experiment || {}, normalizedAppCode),
    };
}

export async function updateLuckyGiftExperiment(appCode, experimentId, patch = {}) {
    const body = {};
    // PATCH 只回传明确要修改的字段：同时携带 status 和 treatment_traffic_percent 会让
    // “暂停”误把放量重置，或“放量”误改实验状态。
    if (patch.treatment_traffic_percent !== undefined && patch.treatment_traffic_percent !== null) {
        body.treatment_traffic_percent = numberValue(patch.treatment_traffic_percent);
    }
    if (patch.status) {
        body.status = String(patch.status);
    }
    const payload = await opsRequest(luckyGiftExperimentPath(experimentId), {
        body,
        method: "PATCH",
        query: { app_code: normalizeAppCodeValue(appCode) },
    });
    return normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode);
}

export async function setLuckyGiftExperimentOverrides(appCode, experimentId, { removes, upserts } = {}) {
    const payload = await opsRequest(`${luckyGiftExperimentPath(experimentId)}/overrides`, {
        body: {
            removes: (Array.isArray(removes) ? removes : [])
                .map((userKey) => String(userKey ?? "").trim())
                .filter(Boolean),
            upserts: normalizeExperimentOverrides(upserts),
        },
        method: "PUT",
        query: { app_code: normalizeAppCodeValue(appCode) },
    });
    return normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode);
}

export async function endLuckyGiftExperiment(appCode, experimentId, winnerArm) {
    // 结束实验没有默认获胜方：获胜配置会被重新发布为最新版本并全量生效，误传空值的后果不可回滚。
    if (winnerArm !== "control" && winnerArm !== "treatment") {
        throw new Error("结束实验必须选择获胜方");
    }
    const payload = await opsRequest(`${luckyGiftExperimentPath(experimentId)}/end`, {
        body: { winner_arm: winnerArm },
        method: "POST",
        query: { app_code: normalizeAppCodeValue(appCode) },
    });
    return {
        ...payload,
        experiment: normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode),
    };
}

export function normalizeLuckyGiftExperiment(experiment = {}, fallbackAppCode = "") {
    const armStats = experiment.arm_stats ?? experiment.armStats;
    return {
        ...experiment,
        app_code: normalizeAppCodeValue(experiment.app_code || experiment.appCode || fallbackAppCode),
        arm_stats: (Array.isArray(armStats) ? armStats : []).map((stat) => ({
            actual_rtp_percent: numberValue(stat.actual_rtp_percent ?? stat.actualRtpPercent),
            arm: String(stat.arm || ""),
            rule_version: numberValue(stat.rule_version ?? stat.ruleVersion),
            strategy_version: normalizeStrategyVersion(stat.strategy_version ?? stat.strategyVersion),
            total_draws: numberValue(stat.total_draws ?? stat.totalDraws),
            total_reward_coins: numberValue(stat.total_reward_coins ?? stat.totalRewardCoins),
            total_spent_coins: numberValue(stat.total_spent_coins ?? stat.totalSpentCoins),
            unique_users: numberValue(stat.unique_users ?? stat.uniqueUsers),
        })),
        control_rule_version: numberValue(experiment.control_rule_version ?? experiment.controlRuleVersion),
        ended_at_ms: numberValue(experiment.ended_at_ms ?? experiment.endedAtMs),
        experiment_id: String(experiment.experiment_id ?? experiment.experimentId ?? ""),
        final_rule_version: numberValue(experiment.final_rule_version ?? experiment.finalRuleVersion),
        name: String(experiment.name || ""),
        overrides: (Array.isArray(experiment.overrides) ? experiment.overrides : []).map((item) => ({
            // arm 只可能是两组之一；未知值按 treatment 处理会放大实验组，按 control 处理保持保守。
            arm: item?.arm === "treatment" ? "treatment" : "control",
            user_key: String(item?.user_key ?? item?.userKey ?? ""),
        })),
        pool_id: String(experiment.pool_id || experiment.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
        started_at_ms: numberValue(experiment.started_at_ms ?? experiment.startedAtMs),
        status: String(experiment.status || "")
            .trim()
            .toLowerCase(),
        treatment_rule_version: numberValue(experiment.treatment_rule_version ?? experiment.treatmentRuleVersion),
        treatment_traffic_percent: numberValue(
            experiment.treatment_traffic_percent ?? experiment.treatmentTrafficPercent,
        ),
        updated_at_ms: numberValue(experiment.updated_at_ms ?? experiment.updatedAtMs),
        winner_arm: String(experiment.winner_arm ?? experiment.winnerArm ?? ""),
        winner_rule_version: numberValue(experiment.winner_rule_version ?? experiment.winnerRuleVersion),
    };
}

function luckyGiftExperimentPath(experimentId) {
    const id = String(experimentId || "").trim();
    if (!id) {
        throw new Error("缺少实验 ID");
    }
    return `/lucky-gifts/experiments/${encodeURIComponent(id)}`;
}

function normalizeExperimentOverrides(overrides) {
    return (Array.isArray(overrides) ? overrides : [])
        .map((item) => ({
            arm: item?.arm === "control" ? "control" : "treatment",
            user_key: String(item?.user_key ?? item?.userKey ?? "").trim(),
        }))
        .filter((item) => item.user_key);
}

export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
    const normalizedPath = path.startsWith("/") ? path : `/${path}`;
    // 复用主后台请求层才能共享 refreshOnce：运行期间 token 过期时先刷新再重放一次请求。
    // 水位写接口携带稳定 adjustment_id，因此认证重放仍由 owner 幂等收敛；409 文案继续原样抛给弹窗保留表单。
    return apiRequest(`/v1/admin/ops-center${normalizedPath}`, {
        body,
        method,
        query,
    });
}

export function buildOpsUrl(path, query = {}) {
    const normalizedPath = path.startsWith("/") ? path : `/${path}`;
    const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin);

    Object.entries(query || {}).forEach(([key, value]) => {
        if (value !== undefined && value !== null && value !== "") {
            url.searchParams.set(key, String(value));
        }
    });

    return url.toString();
}

export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
    const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
    // 实验按 app+pool 独占；这里保持扁平列表，活跃实验与配置行的关联由展示层的 family key 完成。
    const experiments = perApp.flatMap((entry) => entry.experiments || []);
    const normalizedPoolBalances = perApp
        .flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
        .filter((item) => item.app_code);
    const appSummaries = perApp
        .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
        .filter((summary) => summary.app_code);
    const giftHostDiamondRatiosByApp = Object.fromEntries(
        perApp.map((entry) => [normalizeAppCodeValue(entry.appCode), entry.giftHostDiamondRatios ?? null]),
    );

    const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
    const currentConfigByPool = new Map();
    luckyGifts.forEach((config) => {
        const familyKey = luckyGiftPoolFamilyKey(config);
        const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
        const current = currentConfigByPool.get(familyKey);
        // 正常接口只返回每个奖池的最新版本；这里仍按最大 rule_version 收敛，避免兼容接口返回多版本时
        // 由数组顺序把旧策略误判为当前。版本相同则保留首条，确保结果不随返回顺序抖动。
        if (!current || ruleVersion > current.ruleVersion) {
            currentConfigByPool.set(familyKey, {
                ruleVersion,
                strategyVersion: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
            });
        }
    });
    // 账本接口会同时返回同一 app+pool 的 V2/V3 独立水位；最新规则配置才决定哪口是当前奖池。
    // 没有配置可关联的孤立账本继续按当前展示，避免前端误把仍需运营处理的资金静默藏进历史筛选。
    const poolBalances = normalizedPoolBalances.map((pool) => {
        const currentStrategy = currentConfigByPool.get(luckyGiftPoolFamilyKey(pool))?.strategyVersion;
        return {
            ...pool,
            is_historical: Boolean(currentStrategy && currentStrategy !== pool.strategy_version),
        };
    });
    const currentPoolBalances = poolBalances.filter((pool) => !pool.is_historical);

    // KPI 一律跨应用聚合；奖池金额只汇总当前策略，避免历史 V2 与当前 V3 的独立账本重复计入运营水位。
    const summary = {
        activeApps: apps.length,
        configuredPools: publishedConfigs.length,
        enabledPools: publishedConfigs.filter((config) => config.enabled).length,
        failedDraws: sumBy(appSummaries, "failed_draws"),
        grantedDraws: sumBy(appSummaries, "granted_draws"),
        pendingDraws: sumBy(appSummaries, "pending_draws"),
        poolAvailableTotal: sumBy(currentPoolBalances, "available_balance", "availableBalance"),
        poolBalanceTotal: sumBy(currentPoolBalances, "balance", "Balance"),
        poolReserveTotal: sumBy(currentPoolBalances, "reserve_floor", "reserveFloor"),
        totalDraws: sumBy(appSummaries, "total_draws"),
        totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
        totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
    };

    return { apps, appSummaries, experiments, giftHostDiamondRatiosByApp, luckyGifts, poolBalances, summary };
}

export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
    return {
        ...pool,
        app_code: String(pool.app_code || pool.appCode || fallbackAppCode)
            .trim()
            .toLowerCase(),
        available_balance: numberValue(pool.available_balance ?? pool.availableBalance),
        balance: numberValue(pool.balance ?? pool.Balance),
        manual_credit_total: numberValue(pool.manual_credit_total ?? pool.manualCreditTotal),
        manual_debit_total: numberValue(pool.manual_debit_total ?? pool.manualDebitTotal),
        materialized: Boolean(pool.materialized),
        pool_id: String(pool.pool_id || pool.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
        reserve_floor: numberValue(pool.reserve_floor ?? pool.reserveFloor),
        strategy_version: normalizeStrategyVersion(pool.strategy_version ?? pool.strategyVersion),
        total_in: numberValue(pool.total_in ?? pool.totalIn),
        total_out: numberValue(pool.total_out ?? pool.totalOut),
        updated_at_ms: numberValue(pool.updated_at_ms ?? pool.updatedAtMs),
    };
}

export function luckyGiftPoolKey(item = {}) {
    const appCode = String(item.app_code || item.appCode || "")
        .trim()
        .toLowerCase();
    const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
    const strategyVersion = normalizeStrategyVersion(item.strategy_version ?? item.strategyVersion);
    // V2/V3 资金由 owner 按策略版本隔离；任何列表关联或 React rowKey 都必须包含 strategy_version，
    // 否则同一 app+pool 的两口奖池会在前端被覆盖成一行。
    return `${appCode}:${poolID}:${strategyVersion}`;
}

function luckyGiftPoolFamilyKey(item = {}) {
    const appCode = String(item.app_code || item.appCode || "")
        .trim()
        .toLowerCase();
    const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
    return `${appCode}:${poolID}`;
}

function normalizeDrawSummary(summary = {}, appCode = "") {
    return {
        actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
        app_code: String(appCode || summary.app_code || "")
            .trim()
            .toLowerCase(),
        failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws),
        granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws),
        pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws),
        total_draws: numberValue(summary.total_draws ?? summary.totalDraws),
        total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins),
        total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins),
        unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms),
        unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers),
    };
}

function uniqueAppCodes(apps) {
    const seen = new Set();
    const codes = [];
    apps.forEach((item) => {
        const code = String(item?.app_code || item?.appCode || "")
            .trim()
            .toLowerCase();
        if (!code || seen.has(code)) {
            return;
        }
        seen.add(code);
        codes.push(code);
    });
    return codes;
}

function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
    const appCode = String(config.app_code || config.appCode || fallbackAppCode)
        .trim()
        .toLowerCase();
    const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
    const user24hRTPThreshold =
        config.user_24h_rtp_threshold_percent ??
        config.user24HRTPThresholdPercent ??
        config.user24hRTPThresholdPercent ??
        config.user24hRtpThresholdPercent;
    const user24hOrdinaryWinFactor =
        config.user_24h_ordinary_win_factor_percent ??
        config.user24HOrdinaryWinFactorPercent ??
        config.user24hOrdinaryWinFactorPercent;
    return {
        ...config,
        app_code: appCode,
        // rule_version <= 0 表示服务端返回的未发布草稿：列表要标成"默认草稿"并禁止当作已生效规则解读。
        is_default: ruleVersion <= 0,
        pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
        rule_version: ruleVersion,
        // 升级前规则没有 strategy_version，lucky-gift owner 明确按 fixed_v2 解释；列表和编辑表单必须沿用该兼容语义。
        strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
        // 仅在 owner 真正返回新字段时补 canonical key；缺字段代表历史禁用快照，不能在读取阶段伪装成已启用。
        ...(user24hRTPThreshold !== undefined
            ? { user_24h_rtp_threshold_percent: numberValue(user24hRTPThreshold) }
            : {}),
        ...(user24hOrdinaryWinFactor !== undefined
            ? { user_24h_ordinary_win_factor_percent: numberValue(user24hOrdinaryWinFactor) }
            : {}),
    };
}

function normalizePoolAdjustmentResult(payload = {}) {
    const adjustment = payload.adjustment || {};
    return {
        ...payload,
        adjustment: {
            ...adjustment,
            amount_coins: numberValue(adjustment.amount_coins ?? adjustment.amountCoins),
            balance_after: numberValue(adjustment.balance_after ?? adjustment.balanceAfter),
            balance_before: numberValue(adjustment.balance_before ?? adjustment.balanceBefore),
            strategy_version: normalizeStrategyVersion(adjustment.strategy_version ?? adjustment.strategyVersion),
        },
        pool: normalizePoolBalance(payload.pool || {}),
    };
}

function normalizeLuckyGiftUserProfile(profile = {}, fallbackSnapshotAtMs = 0) {
    const identity = profile.identity || {};
    const internalUser = profile.internal_user || profile.internalUser || profile.user || identity.user || {};
    const externalUserID = String(
        profile.external_user_id ??
            profile.externalUserId ??
            identity.external_user_id ??
            identity.externalUserId ??
            "",
    ).trim();
    const userID = String(
        profile.internal_user_id ??
            profile.internalUserId ??
            profile.user_id ??
            profile.userId ??
            internalUser.user_id ??
            internalUser.userId ??
            identity.user_id ??
            identity.userId ??
            "",
    ).trim();
    const identityType = externalUserID
        ? "external"
        : normalizeIdentityType(
              profile.identity_type ?? profile.identityType ?? profile.user_type ?? profile.userType ?? "internal",
          );
    const windows = profile.windows || profile.window_metrics || profile.windowMetrics || {};
    const snapshotAtMs = numberValue(profile.snapshot_at_ms ?? profile.snapshotAtMs ?? fallbackSnapshotAtMs);
    const profileUpdatedAtMs = numberValue(
        profile.aggregated_at_ms ??
            profile.aggregatedAtMs ??
            profile.profile_updated_at_ms ??
            profile.profileUpdatedAtMs ??
            profile.updated_at_ms ??
            profile.updatedAtMs,
    );
    const explicitLag = profile.data_lag_ms ?? profile.dataLagMs;
    const jackpotCounts = profile.jackpot_counts || profile.jackpotCounts || {};

    // 画像接口是独立读模型，字段名可能经过 protobuf JSON 或 admin envelope 转成 camelCase；
    // 在边界一次性归一化，组件只消费稳定模型，且明确丢弃 owner 内部的 strategy_user_id / user_key 哈希。
    return {
        appCode: String(profile.app_code ?? profile.appCode ?? "")
            .trim()
            .toLowerCase(),
        cumulativeSpendJackpotCount: numberValue(
            profile.cumulative_spend_jackpot_count ??
                profile.cumulativeSpendJackpotCount ??
                jackpotCounts.cumulative_spend ??
                jackpotCounts.cumulativeSpend,
        ),
        dataLagMs:
            explicitLag !== undefined
                ? Math.max(0, numberValue(explicitLag))
                : snapshotAtMs && profileUpdatedAtMs
                  ? Math.max(0, snapshotAtMs - profileUpdatedAtMs)
                  : 0,
        downweightActive: Boolean(
            profile.downweight_active ??
            profile.downweightActive ??
            profile.user_24h_rtp_downweight_active ??
            profile.user24hRtpDownweightActive,
        ),
        equivalentDraws: numberValue(profile.equivalent_draws ?? profile.equivalentDraws),
        externalUserId: identityType === "external" ? externalUserID : "",
        failedDrawCount: numberValue(profile.failed_draw_count ?? profile.failedDrawCount),
        firstDrawAtMs: numberValue(profile.first_draw_at_ms ?? profile.firstDrawAtMs),
        grantedDrawCount: numberValue(profile.granted_draw_count ?? profile.grantedDrawCount),
        guaranteeDrawsRemaining: numberValue(
            profile.guarantee_draws_remaining ?? profile.guaranteeDrawsRemaining ?? profile.loss_protection_remaining,
        ),
        guaranteeThreshold: numberValue(
            profile.guarantee_threshold ?? profile.guaranteeThreshold ?? profile.loss_streak_guarantee,
        ),
        highMultiplierOrdinaryWinCount: numberValue(
            profile.high_multiplier_ordinary_win_count ?? profile.highMultiplierOrdinaryWinCount,
        ),
        identityType,
        currentRtpPpm: numberValue(profile.current_rtp_ppm ?? profile.currentRtpPpm),
        hasCurrentRtp: Boolean(profile.has_current_rtp ?? profile.hasCurrentRtp),
        key: `${profile.app_code ?? profile.appCode ?? ""}:${profile.pool_id ?? profile.poolId ?? ""}:${identityType}:${
            identityType === "external" ? externalUserID : userID
        }`,
        lastDrawAtMs: numberValue(profile.last_draw_at_ms ?? profile.lastDrawAtMs),
        latestMultiplierPpm: numberValue(profile.latest_multiplier_ppm ?? profile.latestMultiplierPpm),
        latestRuleVersion: numberValue(
            profile.latest_rule_version ?? profile.latestRuleVersion ?? profile.rule_version,
        ),
        latestStrategyVersion: String(
            profile.latest_strategy_version ?? profile.latestStrategyVersion ?? profile.strategy_version ?? "",
        ).trim(),
        latestWaterZone: String(
            profile.latest_water_zone ?? profile.latestWaterZone ?? profile.water_zone ?? "",
        ).trim(),
        lossStreak: numberValue(profile.loss_streak ?? profile.lossStreak),
        maxMultiplierPpm: numberValue(profile.max_multiplier_ppm ?? profile.maxMultiplierPpm),
        maxRewardCoins: numberValue(profile.max_reward_coins ?? profile.maxRewardCoins),
        ordinaryWinCount: numberValue(profile.ordinary_win_count ?? profile.ordinaryWinCount),
        pendingCumulativeSpendTokens: numberValue(
            profile.pending_spend_jackpot_tokens ??
                profile.pendingSpendJackpotTokens ??
                profile.pending_cumulative_spend_tokens ??
                profile.pendingCumulativeSpendTokens,
        ),
        pendingRtpCompensationTokens: numberValue(
            profile.pending_rtp_compensation_tokens ?? profile.pendingRtpCompensationTokens,
        ),
        poolId: String(profile.pool_id ?? profile.poolId ?? "").trim(),
        pendingDrawCount: numberValue(profile.pending_draw_count ?? profile.pendingDrawCount),
        paidDraws: numberValue(profile.paid_draws ?? profile.paidDraws),
        profileUpdatedAtMs,
        rtpCompensationJackpotCount: numberValue(
            profile.rtp_compensation_jackpot_count ??
                profile.rtpCompensationJackpotCount ??
                jackpotCounts.rtp_compensation ??
                jackpotCounts.rtpCompensation,
        ),
        snapshotAtMs,
        stage: String(profile.stage ?? profile.current_stage ?? profile.currentStage ?? "").trim(),
        baseRewardCoins: numberValue(profile.base_reward_coins ?? profile.baseRewardCoins),
        guaranteeHitCount: numberValue(profile.guarantee_hit_count ?? profile.guaranteeHitCount),
        user: {
            avatar: String(internalUser.avatar ?? internalUser.avatar_url ?? internalUser.avatarUrl ?? "").trim(),
            defaultDisplayUserId: String(
                internalUser.default_display_user_id ?? internalUser.defaultDisplayUserId ?? "",
            ).trim(),
            displayUserId: String(internalUser.display_user_id ?? internalUser.displayUserId ?? "").trim(),
            prettyDisplayUserId: String(
                internalUser.pretty_display_user_id ?? internalUser.prettyDisplayUserId ?? "",
            ).trim(),
            prettyId: String(internalUser.pretty_id ?? internalUser.prettyId ?? "").trim(),
            status: String(internalUser.status || "").trim(),
            userId: identityType === "internal" ? userID : "",
            username: String(internalUser.username ?? internalUser.name ?? "").trim(),
        },
        user24hOrdinaryWinFactorPpm: numberValue(
            profile.downweight_factor_ppm ??
                profile.downweightFactorPpm ??
                profile.user_24h_ordinary_win_factor_ppm ??
                profile.user24hOrdinaryWinFactorPpm,
        ),
        user24hRtpThresholdPpm: numberValue(profile.user_24h_rtp_threshold_ppm ?? profile.user24hRtpThresholdPpm),
        windows: {
            fortyEightHours: normalizeLuckyGiftWindowMetric(
                windows.forty_eight_hours ??
                    windows.fortyEightHours ??
                    windows.last_48h ??
                    windows.h48 ??
                    profile.forty_eight_hours ??
                    profile.fortyEightHours,
            ),
            lifetime: normalizeLuckyGiftWindowMetric(
                windows.lifetime ??
                    windows.all ??
                    windows.total ??
                    profile.lifetime ??
                    profile.all_time ??
                    profile.allTime,
            ),
            oneHour: normalizeLuckyGiftWindowMetric(
                windows.one_hour ??
                    windows.oneHour ??
                    windows.last_1h ??
                    windows.h1 ??
                    profile.one_hour ??
                    profile.oneHour,
            ),
            twentyFourHours: normalizeLuckyGiftWindowMetric(
                windows.twenty_four_hours ??
                    windows.twentyFourHours ??
                    windows.last_24h ??
                    windows.h24 ??
                    profile.twenty_four_hours ??
                    profile.twentyFourHours,
            ),
        },
        zeroDrawCount: numberValue(profile.zero_draw_count ?? profile.zeroDrawCount),
    };
}

function normalizeLuckyGiftWindowMetric(metric = {}) {
    const wagerCoins = numberValue(
        metric.wager_coins ?? metric.wagerCoins ?? metric.spent_coins ?? metric.spentCoins ?? metric.consumed_coins,
    );
    const payoutCoins = numberValue(
        metric.payout_coins ?? metric.payoutCoins ?? metric.reward_coins ?? metric.rewardCoins ?? metric.returned_coins,
    );
    return {
        drawCount: numberValue(metric.draw_count ?? metric.drawCount ?? metric.draws ?? metric.total_draws),
        hasRtp: Boolean(metric.has_rtp ?? metric.hasRtp ?? wagerCoins > 0),
        // 净输赢固定采用用户视角“返奖 - 消耗”，不直接接受含义可能相反的平台 profit 字段。
        netCoins: payoutCoins - wagerCoins,
        payoutCoins,
        rtpPpm: numberValue(metric.rtp_ppm ?? metric.rtpPpm ?? metric.actual_rtp_ppm ?? metric.actualRtpPpm),
        wagerCoins,
    };
}

function normalizeLuckyGiftMultiplierMetric(metric = {}) {
    const hitType = String(metric.hit_type ?? metric.hitType ?? "")
        .trim()
        .toLowerCase();
    const drawCount = numberValue(metric.draw_count ?? metric.drawCount);
    const ordinaryHitCount =
        numberValue(
            metric.ordinary_hit_count ?? metric.ordinaryHitCount ?? metric.ordinary_count ?? metric.ordinaryCount,
        ) || (hitType === "ordinary" ? drawCount : 0);
    const rtpCompensationJackpotCount =
        numberValue(metric.rtp_compensation_jackpot_count ?? metric.rtpCompensationJackpotCount) ||
        (hitType === "rtp_compensation" ? drawCount : 0);
    const cumulativeSpendJackpotCount =
        numberValue(metric.cumulative_spend_jackpot_count ?? metric.cumulativeSpendJackpotCount) ||
        (["cumulative_spend", "spend_milestone"].includes(hitType) ? drawCount : 0);
    const zeroDrawCount =
        numberValue(metric.zero_draw_count ?? metric.zeroDrawCount) || (hitType === "zero" ? drawCount : 0);
    return {
        cumulativeSpendJackpotCount,
        hitCount:
            numberValue(metric.hit_count ?? metric.hitCount) ||
            ordinaryHitCount + rtpCompensationJackpotCount + cumulativeSpendJackpotCount + zeroDrawCount,
        multiplierPpm: numberValue(metric.multiplier_ppm ?? metric.multiplierPpm),
        ordinaryHitCount,
        payoutCoins: numberValue(
            metric.payout_coins ?? metric.payoutCoins ?? metric.reward_coins ?? metric.rewardCoins,
        ),
        rtpCompensationJackpotCount,
        wagerCoins: numberValue(metric.wager_coins ?? metric.wagerCoins),
        zeroDrawCount,
    };
}

function aggregateLuckyGiftMultiplierMetrics(metrics) {
    const byMultiplier = new Map();
    metrics.map(normalizeLuckyGiftMultiplierMetric).forEach((metric) => {
        const current = byMultiplier.get(metric.multiplierPpm) || {
            cumulativeSpendJackpotCount: 0,
            hitCount: 0,
            multiplierPpm: metric.multiplierPpm,
            ordinaryHitCount: 0,
            payoutCoins: 0,
            rtpCompensationJackpotCount: 0,
            wagerCoins: 0,
            zeroDrawCount: 0,
        };
        current.cumulativeSpendJackpotCount += metric.cumulativeSpendJackpotCount;
        current.hitCount += metric.hitCount;
        current.ordinaryHitCount += metric.ordinaryHitCount;
        current.payoutCoins += metric.payoutCoins;
        current.rtpCompensationJackpotCount += metric.rtpCompensationJackpotCount;
        current.wagerCoins += metric.wagerCoins;
        current.zeroDrawCount += metric.zeroDrawCount;
        byMultiplier.set(metric.multiplierPpm, current);
    });
    return [...byMultiplier.values()];
}

function normalizeLuckyGiftJackpotHit(hit = {}) {
    const mechanism = String(
        hit.mechanism ?? hit.jackpot_mechanism ?? hit.jackpotMechanism ?? hit.jackpot_type ?? hit.jackpotType ?? "",
    ).trim();
    return {
        conditions: normalizeLuckyGiftJackpotConditions(
            hit.conditions ??
                hit.conditions_json ??
                hit.conditionsJson ??
                hit.condition_snapshot_json ??
                hit.conditionSnapshotJson,
        ),
        drawId: String(hit.draw_id ?? hit.drawId ?? "").trim(),
        eventKey: String(hit.event_key ?? hit.eventKey ?? "").trim(),
        mechanism,
        multiplierPpm: numberValue(hit.multiplier_ppm ?? hit.multiplierPpm),
        occurredAtMs: numberValue(hit.occurred_at_ms ?? hit.occurredAtMs ?? hit.created_at_ms ?? hit.createdAtMs),
        payoutCoins: numberValue(hit.payout_coins ?? hit.payoutCoins ?? hit.reward_coins ?? hit.rewardCoins),
        reason: String(
            hit.reason_summary ??
                hit.reasonSummary ??
                hit.reason ??
                hit.final_reason ??
                hit.finalReason ??
                hit.decision_reason ??
                hit.decisionReason ??
                "",
        ).trim(),
        reasonCode: String(hit.reason_code ?? hit.reasonCode ?? "").trim(),
        requestId: String(hit.request_id ?? hit.requestId ?? "").trim(),
        ruleVersion: numberValue(hit.rule_version ?? hit.ruleVersion),
        stage: String(hit.stage ?? "").trim(),
        tierId: String(hit.tier_id ?? hit.tierId ?? "").trim(),
        wagerCoins: numberValue(hit.wager_coins ?? hit.wagerCoins),
    };
}

function normalizeLuckyGiftJackpotConditions(value) {
    const parsed = parseJSONValue(value);
    if (!Array.isArray(parsed)) {
        return parsed;
    }
    return parsed.map((condition) => ({
        actual: String(condition.actual ?? condition.actual_value ?? condition.actualValue ?? "").trim(),
        denominator: numberValue(condition.denominator),
        factorPpm: numberValue(condition.factor_ppm ?? condition.factorPpm),
        label: String(condition.label || "").trim(),
        limitPpm: numberValue(condition.limit_ppm ?? condition.limitPpm),
        name: String(condition.name || "").trim(),
        numerator: numberValue(condition.numerator),
        operator: String(condition.operator || "").trim(),
        passed: Boolean(condition.passed),
        ratioPpm: numberValue(condition.ratio_ppm ?? condition.ratioPpm),
        reason: String(condition.reason || "").trim(),
        threshold: String(condition.threshold ?? condition.threshold_value ?? condition.thresholdValue ?? "").trim(),
    }));
}

function normalizeIdentityType(value) {
    const normalized = String(value || "all")
        .trim()
        .toLowerCase();
    return normalized === "internal" || normalized === "external" ? normalized : "all";
}

function parseJSONValue(value) {
    if (!value) {
        return {};
    }
    if (typeof value === "object") {
        return value;
    }
    try {
        return JSON.parse(value);
    } catch {
        return { detail: String(value) };
    }
}

function normalizeProfileItems(value) {
    const items = normalizeItems(value);
    if (items.length || !value || typeof value !== "object") {
        return items;
    }
    return Array.isArray(value.profiles) ? value.profiles : [];
}

function opsRelativePath(operationPath) {
    const prefix = "/v1/admin/ops-center";
    if (!operationPath.startsWith(prefix)) {
        throw new Error(`运营接口不属于 ops-center: ${operationPath}`);
    }
    return operationPath.slice(prefix.length);
}

function normalizeStrategyVersion(value) {
    return String(value || "fixed_v2")
        .trim()
        .toLowerCase();
}

function normalizeAppCodeValue(value) {
    return String(value || "")
        .trim()
        .toLowerCase();
}

function luckyGiftConfigPayload(config = {}) {
    const appCode = String(config.app_code || config.appCode || "")
        .trim()
        .toLowerCase();
    const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
    const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2")
        .trim()
        .toLowerCase();
    const user24hRTPThreshold =
        config.user_24h_rtp_threshold_percent ??
        config.user24HRTPThresholdPercent ??
        config.user24hRTPThresholdPercent ??
        config.user24hRtpThresholdPercent;
    const user24hOrdinaryWinFactor =
        config.user_24h_ordinary_win_factor_percent ??
        config.user24HOrdinaryWinFactorPercent ??
        config.user24hOrdinaryWinFactorPercent;
    const hasUser24hRTPConfig = user24hRTPThreshold !== undefined || user24hOrdinaryWinFactor !== undefined;

    // 规则版本是不可变快照，PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让
    // 空 strategy_version 被服务端按 fixed_v2 发布，或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。
    return {
        anchor_daily_payout_cap: numberValue(config.anchor_daily_payout_cap ?? config.anchorDailyPayoutCap),
        anchor_rate_percent: numberValue(config.anchor_rate_percent ?? config.anchorRatePercent),
        app_code: appCode,
        control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent),
        device_daily_payout_cap: numberValue(config.device_daily_payout_cap ?? config.deviceDailyPayoutCap),
        effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMs ?? config.effectiveFromMS),
        enabled: Boolean(config.enabled),
        gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference),
        high_water_nonzero_factor_percent: numberValue(
            config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent,
        ),
        high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins),
        // dynamic_v3 已改为通过带审计的 pool credit 接口注资；即使旧表单快照仍带 1m，也不能在发布新版本时继续隐式 seed。
        initial_pool_coins:
            strategyVersion === "dynamic_v3" ? 0 : numberValue(config.initial_pool_coins ?? config.initialPoolCoins),
        jackpot_global_rtp_max_percent: numberValue(
            config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
        ),
        jackpot_multipliers: numberArray(config.jackpot_multipliers ?? config.jackpotMultipliers),
        jackpot_spend_threshold_coins: numberValue(
            config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins,
        ),
        jackpot_user_48h_rtp_max_percent: numberValue(
            config.jackpot_user_48h_rtp_max_percent ??
                config.jackpotUser48hRTPMaxPercent ??
                config.jackpotUser48hRtpMaxPercent ??
                config.jackpot_user_72h_rtp_max_percent ??
                config.jackpotUser72hRTPMaxPercent ??
                config.jackpotUser72hRtpMaxPercent,
        ),
        jackpot_user_round_rtp_max_percent: numberValue(
            config.jackpot_user_round_rtp_max_percent ??
                config.jackpotUserRoundRTPMaxPercent ??
                config.jackpotUserRoundRtpMaxPercent ??
                config.jackpot_user_day_rtp_max_percent ??
                config.jackpotUserDayRTPMaxPercent ??
                config.jackpotUserDayRtpMaxPercent,
        ),
        loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee),
        low_water_nonzero_factor_percent: numberValue(
            config.low_water_nonzero_factor_percent ?? config.lowWaterNonzeroFactorPercent,
        ),
        low_watermark_coins: numberValue(config.low_watermark_coins ?? config.lowWatermarkCoins),
        max_jackpot_hits_per_user_day: numberValue(
            config.max_jackpot_hits_per_user_day ?? config.maxJackpotHitsPerUserDay,
        ),
        max_single_payout: numberValue(config.max_single_payout ?? config.maxSinglePayout),
        normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws),
        novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws),
        pool_id: poolID,
        pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent),
        profit_rate_percent: numberValue(config.profit_rate_percent ?? config.profitRatePercent),
        recharge_boost_factor_percent: numberValue(
            config.recharge_boost_factor_percent ?? config.rechargeBoostFactorPercent,
        ),
        recharge_boost_window_ms: numberValue(
            config.recharge_boost_window_ms ?? config.rechargeBoostWindowMs ?? config.rechargeBoostWindowMS,
        ),
        room_hourly_payout_cap: numberValue(config.room_hourly_payout_cap ?? config.roomHourlyPayoutCap),
        settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
        stages: normalizeStages(config.stages),
        strategy_version: strategyVersion,
        // 旧调用方不带这组字段时保持旧 payload 形状；任一字段出现就成组发送，避免只发布一半策略。
        ...(hasUser24hRTPConfig
            ? {
                  user_24h_ordinary_win_factor_percent: numberValue(
                      user24hOrdinaryWinFactor ?? USER_24H_RTP_DISABLED_FACTOR_PERCENT,
                  ),
                  user_24h_rtp_threshold_percent: numberValue(user24hRTPThreshold),
              }
            : {}),
        user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap),
        user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap),
        // V2 高水位加权是 fixed_v2 专属；dynamic_v3 有独立水位因子，owner 会拒绝混填，这里直接归零。
        v2_high_water_boost_threshold_coins:
            strategyVersion === "dynamic_v3"
                ? 0
                : numberValue(config.v2_high_water_boost_threshold_coins ?? config.v2HighWaterBoostThresholdCoins),
        v2_high_water_boost_factor_percent:
            strategyVersion === "dynamic_v3"
                ? 0
                : numberValue(config.v2_high_water_boost_factor_percent ?? config.v2HighWaterBoostFactorPercent),
        v2_high_water_boost_recover_coins:
            strategyVersion === "dynamic_v3"
                ? 0
                : numberValue(config.v2_high_water_boost_recover_coins ?? config.v2HighWaterBoostRecoverCoins),
        target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent),
    };
}

function normalizeItems(value) {
    if (Array.isArray(value)) {
        return value;
    }
    if (Array.isArray(value?.items)) {
        return value.items;
    }
    if (Array.isArray(value?.list)) {
        return value.list;
    }
    return [];
}

function sumBy(items, key, fallbackKey) {
    return items.reduce(
        (total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)),
        0,
    );
}

function numberValue(value) {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : 0;
}

function finitePercentOrNull(value) {
    const parsed = Number(value);
    return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : null;
}

function numberArray(values) {
    if (!Array.isArray(values)) {
        return [];
    }
    // 不在传输层静默删除 0/负倍率；表单负责拦截，若有遗漏也应让 owner 返回明确校验错误，
    // 否则数组位置变化会掩盖运营实际提交的错误值。
    return values.map(numberValue);
}

function normalizeStages(stages) {
    if (!Array.isArray(stages)) {
        return [];
    }
    return stages.map((stage) => ({
        min_recharge_30d_coins: numberValue(stage.min_recharge_30d_coins ?? stage.minRecharge30DCoins),
        min_recharge_7d_coins: numberValue(stage.min_recharge_7d_coins ?? stage.minRecharge7DCoins),
        stage: stage.stage || stage.Stage || "",
        tiers: Array.isArray(stage.tiers)
            ? stage.tiers.map((tier) => ({
                  enabled: tier.enabled !== false,
                  high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly),
                  multiplier: numberValue(tier.multiplier),
                  probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent),
                  tier_id: tier.tier_id || tier.tierId || "",
              }))
            : [],
    }));
}
