import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
import {
    adjustLuckyGiftPool,
    buildOpsUrl,
    createLuckyGiftExperiment,
    endLuckyGiftExperiment,
    fetchLuckyGiftDraws,
    fetchLuckyGiftExperiments,
    fetchOpsDashboard,
    luckyGiftPoolKey,
    normalizeDashboard,
    opsRequest,
    OPS_API_PREFIX,
    saveLuckyGiftConfig,
    setLuckyGiftExperimentOverrides,
    updateLuckyGiftExperiment,
} from "./api.js";

afterEach(() => {
    vi.restoreAllMocks();
    setAccessToken("");
    setRefreshHandler(null);
    setUnauthorizedHandler(null);
});

function jsonResponse(data) {
    return new Response(JSON.stringify({ code: 0, data }), {
        headers: { "Content-Type": "application/json" },
        status: 200,
    });
}

test("builds ops api urls under the dedicated prefix", () => {
    const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" });

    expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center");
    expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
});

test("refreshes a stale access token before retrying an ops-center request", async () => {
    setAccessToken("stale-token");
    setRefreshHandler(async () => {
        setAccessToken("fresh-token");
        return true;
    });
    vi.spyOn(window, "fetch")
        .mockResolvedValueOnce(
            new Response(JSON.stringify({ code: 40100, message: "访问凭证已失效" }), {
                headers: { "Content-Type": "application/json" },
                status: 401,
            }),
        )
        .mockResolvedValueOnce(jsonResponse({ items: [{ app_code: "lalu" }] }));

    await expect(opsRequest("/apps")).resolves.toEqual({ items: [{ app_code: "lalu" }] });
    expect(window.fetch).toHaveBeenCalledTimes(2);
    expect(window.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer stale-token");
    expect(window.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer fresh-token");
});

test("loads every pool config per app through the plural configs endpoint", async () => {
    const calls = [];
    const requests = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
        const text = String(url);
        calls.push(text);
        requests.push({ init, url: text });
        if (text.endsWith("/apps")) {
            return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
        }
        if (text.includes("/lucky-gifts/configs?")) {
            // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池，复数接口必须全部带回。
            return jsonResponse([
                { app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
                { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 },
            ]);
        }
        if (text.includes("/lucky-gifts/pools?")) {
            return jsonResponse({
                items: [
                    {
                        app_code: "lalu",
                        available_balance: 37950017,
                        balance: 38559017,
                        materialized: true,
                        pool_id: "lucky",
                        reserve_floor: 609000,
                    },
                    {
                        app_code: "lalu",
                        available_balance: 361500,
                        balance: 382500,
                        materialized: false,
                        pool_id: "default",
                        reserve_floor: 21000,
                    },
                ],
            });
        }
        if (text.includes("/lucky-gifts/summary?")) {
            return jsonResponse({
                granted_draws: 9,
                pending_draws: 1,
                total_draws: 10,
                total_reward_coins: 900,
                total_spent_coins: 1000,
            });
        }
        if (text.includes("/lucky-gifts/experiments?")) {
            return jsonResponse({
                items: [
                    {
                        experiment_id: "exp-1",
                        name: "raise-rtp",
                        pool_id: "lucky",
                        status: "running",
                        treatment_traffic_percent: 5,
                    },
                ],
            });
        }
        if (text.includes("/admin/operations/gift-diamond-ratios?")) {
            return jsonResponse({
                items: [
                    { giftTypeCode: "lucky", ratioPercent: "10.00", returnCoinRatioPercent: "0.00" },
                    { giftTypeCode: "super_lucky", ratioPercent: "1.00", returnCoinRatioPercent: "0.00" },
                ],
                regionId: 0,
            });
        }
        return jsonResponse({ items: [] });
    });

    const data = await fetchOpsDashboard();

    expect(calls).toEqual([
        "http://localhost:3000/api/v1/admin/ops-center/apps",
        "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu",
        "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
        "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
        // dashboard 扇出不带 with_stats/status：实验列表只驱动实验徽章和发布封锁。
        "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
        "http://localhost:3000/api/v1/admin/operations/gift-diamond-ratios?region_id=0",
    ]);
    const ratioRequest = requests.find((request) => request.url.includes("gift-diamond-ratios"));
    expect(ratioRequest.init.headers["X-App-Code"]).toBe("lalu");
    // 幸运礼物资金预留只读取主播钻石 ratioPercent；线上返还金币为 0，不进入该表单。
    expect(data.giftHostDiamondRatiosByApp).toEqual({ lalu: { lucky: 10, super_lucky: 1 } });
    expect(data.experiments).toEqual([
        expect.objectContaining({ app_code: "lalu", experiment_id: "exp-1", pool_id: "lucky", status: "running" }),
    ]);
    expect(data.luckyGifts).toEqual([
        expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
        expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }),
    ]);
    expect(data.summary).toMatchObject({
        activeApps: 1,
        configuredPools: 2,
        enabledPools: 1,
        poolBalanceTotal: 38941517,
        totalDraws: 10,
    });
});

test("falls back to the server default draft when an app has no published config", async () => {
    const calls = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url) => {
        const text = String(url);
        calls.push(text);
        if (text.endsWith("/apps")) {
            return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] });
        }
        if (text.includes("/lucky-gifts/configs?")) {
            return jsonResponse([]);
        }
        if (text.includes("/lucky-gifts/config?")) {
            return jsonResponse({
                app_code: "yumi",
                enabled: false,
                pool_id: "default",
                rule_version: 0,
                target_rtp_percent: 95,
            });
        }
        return jsonResponse({ items: [] });
    });

    const data = await fetchOpsDashboard();

    expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi");
    expect(data.luckyGifts).toEqual([
        expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }),
    ]);
});

test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => {
    const calls = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url) => {
        calls.push(String(url));
        return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 });
    });

    const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" });
    await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" });

    const numericParams = new URL(calls[0]).searchParams;
    expect(numericParams.get("app_code")).toBe("lalu");
    expect(numericParams.get("pool_id")).toBe("lucky");
    expect(numericParams.get("user_id")).toBe("10001");
    expect(numericParams.get("external_user_id")).toBeNull();
    const externalParams = new URL(calls[1]).searchParams;
    expect(externalParams.get("external_user_id")).toBe("ext-abc");
    expect(externalParams.get("user_id")).toBeNull();
    expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 });
});

test("saves lucky gift config through admin ops route", async () => {
    const calls = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
        calls.push({ body: init.body, method: init.method, url: String(url) });
        return jsonResponse({ app_code: "aslan", pool_id: "default" });
    });

    await saveLuckyGiftConfig({
        anchor_daily_payout_cap: 12_312_000,
        anchor_rate_percent: 1,
        app_code: "aslan",
        control_band_percent: 3,
        device_daily_payout_cap: 1_026_000,
        enabled: true,
        gift_price_reference: 100,
        high_water_nonzero_factor_percent: 130,
        high_watermark_coins: 20_000_000,
        initial_pool_coins: 1_000_000,
        jackpot_global_rtp_max_percent: 98,
        jackpot_multipliers: [200, 500, 1000],
        jackpot_spend_threshold_coins: 50_000,
        jackpot_user_72h_rtp_max_percent: 96,
        jackpot_user_day_rtp_max_percent: 96,
        loss_streak_guarantee: 5,
        low_water_nonzero_factor_percent: 70,
        low_watermark_coins: 10_000_000,
        max_jackpot_hits_per_user_day: 5,
        max_single_payout: 50_000,
        normal_max_equivalent_draws: 20_000,
        novice_max_equivalent_draws: 2_000,
        pool_id: "default",
        pool_rate_percent: 98,
        profit_rate_percent: 1,
        recharge_boost_factor_percent: 110,
        recharge_boost_window_ms: 300_000,
        room_hourly_payout_cap: 684_000,
        settlement_window_wager: 1_000_000,
        stages: [
            {
                min_recharge_30d_coins: 0,
                min_recharge_7d_coins: 0,
                stage: "novice",
                tiers: [
                    { multiplier: 1, probabilityPercent: 99.99, enabled: true },
                    {
                        enabled: true,
                        highWaterOnly: true,
                        multiplier: 200,
                        probabilityPercent: 0.01,
                        tierId: "novice_200x",
                    },
                ],
            },
        ],
        strategy_version: "dynamic_v3",
        target_rtp_percent: 98,
        user_daily_payout_cap: 615_600,
        user_hourly_payout_cap: 34_200,
    });

    expect(calls[0].method).toBe("PUT");
    expect(calls[0].url).toBe(
        "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default",
    );
    // 精确比较完整 owner payload；子集断言会让新增/遗漏的策略字段在回归中静默通过。
    expect(JSON.parse(calls[0].body)).toEqual({
        anchor_daily_payout_cap: 12_312_000,
        anchor_rate_percent: 1,
        app_code: "aslan",
        control_band_percent: 3,
        device_daily_payout_cap: 1_026_000,
        effective_from_ms: 0,
        enabled: true,
        gift_price_reference: 100,
        high_water_nonzero_factor_percent: 130,
        high_watermark_coins: 20_000_000,
        initial_pool_coins: 0,
        jackpot_global_rtp_max_percent: 98,
        jackpot_multipliers: [200, 500, 1000],
        jackpot_spend_threshold_coins: 50_000,
        jackpot_user_48h_rtp_max_percent: 96,
        jackpot_user_round_rtp_max_percent: 96,
        loss_streak_guarantee: 5,
        low_water_nonzero_factor_percent: 70,
        low_watermark_coins: 10_000_000,
        max_jackpot_hits_per_user_day: 5,
        max_single_payout: 50_000,
        normal_max_equivalent_draws: 20_000,
        novice_max_equivalent_draws: 2_000,
        pool_id: "default",
        pool_rate_percent: 98,
        profit_rate_percent: 1,
        recharge_boost_factor_percent: 110,
        recharge_boost_window_ms: 300_000,
        room_hourly_payout_cap: 684_000,
        settlement_window_wager: 1_000_000,
        stages: [
            {
                min_recharge_30d_coins: 0,
                min_recharge_7d_coins: 0,
                stage: "novice",
                tiers: [
                    {
                        enabled: true,
                        high_water_only: false,
                        multiplier: 1,
                        probability_percent: 99.99,
                        tier_id: "",
                    },
                    {
                        enabled: true,
                        high_water_only: true,
                        multiplier: 200,
                        probability_percent: 0.01,
                        tier_id: "novice_200x",
                    },
                ],
            },
        ],
        strategy_version: "dynamic_v3",
        target_rtp_percent: 98,
        user_daily_payout_cap: 615_600,
        user_hourly_payout_cap: 34_200,
        // dynamic_v3 不解释 V2 高水位加权；HTTP payload 契约要求三个字段显式归零，owner 拒绝混填。
        v2_high_water_boost_factor_percent: 0,
        v2_high_water_boost_recover_coins: 0,
        v2_high_water_boost_threshold_coins: 0,
    });
});

test("keeps fixed_v2 and dynamic_v3 balances independent for the same app and pool", () => {
    const data = normalizeDashboard({
        apps: [{ app_code: "lalu" }],
        perApp: [
            {
                appCode: "lalu",
                poolBalances: [
                    {
                        availableBalance: 700,
                        balance: 900,
                        manualCreditTotal: 100,
                        manualDebitTotal: 20,
                        poolId: "lucky",
                        reserveFloor: 200,
                        strategyVersion: "fixed_v2",
                    },
                    {
                        available_balance: 3_000,
                        balance: 4_000,
                        manual_credit_total: 2_000,
                        manual_debit_total: 500,
                        pool_id: "lucky",
                        reserve_floor: 1_000,
                        strategy_version: "dynamic_v3",
                    },
                ],
            },
        ],
    });

    expect(data.poolBalances).toHaveLength(2);
    expect(new Set(data.poolBalances.map(luckyGiftPoolKey))).toEqual(
        new Set(["lalu:lucky:fixed_v2", "lalu:lucky:dynamic_v3"]),
    );
    expect(data.poolBalances[0]).toMatchObject({ manual_credit_total: 100, manual_debit_total: 20 });
    expect(data.poolBalances[1]).toMatchObject({ manual_credit_total: 2_000, manual_debit_total: 500 });
    expect(data.summary.poolBalanceTotal).toBe(4_900);
});

test.each([
    ["in", "credit"],
    ["out", "debit"],
])("posts %s pool adjustments with the strategy identity and idempotency key", async (direction, route) => {
    const calls = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
        calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
        return jsonResponse({
            adjustment: {
                adjustment_id: "adjust-1",
                amount_coins: 1_000,
                balance_after: direction === "out" ? 9_000 : 11_000,
                balance_before: 10_000,
                strategy_version: "dynamic_v3",
            },
            idempotent_replay: false,
            pool: {
                app_code: "lalu",
                available_balance: 8_000,
                balance: direction === "out" ? 9_000 : 11_000,
                manual_credit_total: direction === "in" ? 1_000 : 0,
                manual_debit_total: direction === "out" ? 1_000 : 0,
                materialized: true,
                pool_id: "lucky",
                reserve_floor: 1_000,
                strategy_version: "dynamic_v3",
            },
        });
    });

    const result = await adjustLuckyGiftPool({
        adjustmentId: "adjust-1",
        amountCoins: 1_000,
        appCode: "LALU",
        direction,
        poolId: "lucky",
        reason: "  运营调节  ",
        strategyVersion: "dynamic_v3",
    });

    expect(calls).toEqual([
        {
            body: { adjustment_id: "adjust-1", amount_coins: 1_000, reason: "运营调节" },
            method: "POST",
            url: `http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools/${route}?app_code=lalu&pool_id=lucky&strategy_version=dynamic_v3`,
        },
    ]);
    expect(result).toMatchObject({
        adjustment: { balance_before: 10_000, strategy_version: "dynamic_v3" },
        pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" },
    });
});

test("keeps the dashboard usable when the experiments endpoint is not deployed yet", async () => {
    vi.spyOn(window, "fetch").mockImplementation(async (url) => {
        const text = String(url);
        if (text.endsWith("/apps")) {
            return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
        }
        if (text.includes("/lucky-gifts/experiments?")) {
            return new Response(JSON.stringify({ code: 40400, message: "接口不存在或后端服务未更新" }), {
                headers: { "Content-Type": "application/json" },
                status: 404,
            });
        }
        if (text.includes("/lucky-gifts/configs?")) {
            return jsonResponse([{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3 }]);
        }
        return jsonResponse({ items: [] });
    });

    const data = await fetchOpsDashboard();

    // 实验接口晚于面板上线；请求失败必须降级为空列表而不是整页失败，
    // 活跃实验期间的常规发布仍由 owner 以 409 兜底。
    expect(data.experiments).toEqual([]);
    expect(data.luckyGifts).toHaveLength(1);
});

test("loads experiments with optional stats and normalizes arm data", async () => {
    const calls = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url) => {
        calls.push(String(url));
        return jsonResponse({
            items: [
                {
                    arm_stats: [
                        {
                            actual_rtp_percent: "95.5",
                            arm: "control",
                            rule_version: 3,
                            strategy_version: "dynamic_v3",
                            total_draws: "10",
                            total_reward_coins: 955,
                            total_spent_coins: 1000,
                            unique_users: 4,
                        },
                    ],
                    control_rule_version: 3,
                    experiment_id: "exp-1",
                    name: "raise-rtp",
                    overrides: [{ arm: "treatment", user_key: 1001 }],
                    pool_id: "lucky",
                    status: "RUNNING",
                    treatment_rule_version: 4,
                    treatment_traffic_percent: "12.5",
                },
            ],
        });
    });

    const items = await fetchLuckyGiftExperiments("LALU", { poolId: "lucky", withStats: true });

    expect(calls).toEqual([
        "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu&pool_id=lucky&with_stats=1",
    ]);
    expect(items).toEqual([
        expect.objectContaining({
            app_code: "lalu",
            control_rule_version: 3,
            experiment_id: "exp-1",
            overrides: [{ arm: "treatment", user_key: "1001" }],
            status: "running",
            treatment_rule_version: 4,
            treatment_traffic_percent: 12.5,
        }),
    ]);
    expect(items[0].arm_stats[0]).toEqual({
        actual_rtp_percent: 95.5,
        arm: "control",
        rule_version: 3,
        strategy_version: "dynamic_v3",
        total_draws: 10,
        total_reward_coins: 955,
        total_spent_coins: 1000,
        unique_users: 4,
    });
});

test("creates an experiment with a forcibly enabled treatment config", async () => {
    const calls = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
        calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
        return jsonResponse({
            experiment: { experiment_id: "exp-9", status: "running", treatment_rule_version: 4 },
            treatment_config: { app_code: "lalu", pool_id: "lucky", rule_version: 4 },
        });
    });

    const result = await createLuckyGiftExperiment("lalu", {
        config: {
            app_code: "lalu",
            enabled: false,
            initial_pool_coins: 1_000_000,
            pool_id: "lucky",
            strategy_version: "dynamic_v3",
        },
        name: " 提高RTP ",
        overrides: [{ arm: "treatment", user_key: " 1001 " }, { user_key: "" }],
        treatment_traffic_percent: 5,
    });

    expect(calls[0].method).toBe("POST");
    expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu");
    expect(calls[0].body.name).toBe("提高RTP");
    expect(calls[0].body.treatment_traffic_percent).toBe(5);
    // 空 user_key 在传输层剔除；实验组配置必须直接可运行，dynamic_v3 的隐式 seed 同样清零。
    expect(calls[0].body.overrides).toEqual([{ arm: "treatment", user_key: "1001" }]);
    expect(calls[0].body.config).toMatchObject({
        app_code: "lalu",
        enabled: true,
        initial_pool_coins: 0,
        pool_id: "lucky",
        strategy_version: "dynamic_v3",
    });
    expect(result.experiment).toMatchObject({ experiment_id: "exp-9", status: "running", treatment_rule_version: 4 });
});

test.each([
    [{ treatment_traffic_percent: 20 }, { treatment_traffic_percent: 20 }],
    [{ status: "paused" }, { status: "paused" }],
])("patches only the provided experiment fields %j", async (patch, expectedBody) => {
    const calls = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
        calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
        return jsonResponse({ experiment: { experiment_id: "exp-9", status: "running" } });
    });

    const experiment = await updateLuckyGiftExperiment("lalu", "exp-9", patch);

    // PATCH 只携带明确修改的字段：暂停不应重置放量，放量不应改状态。
    expect(calls).toEqual([
        {
            body: expectedBody,
            method: "PATCH",
            url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9?app_code=lalu",
        },
    ]);
    expect(experiment).toMatchObject({ experiment_id: "exp-9", status: "running" });
});

test("replaces overrides and ends the experiment through dedicated routes", async () => {
    const calls = [];
    vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
        calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
        return jsonResponse({
            experiment: { experiment_id: "exp-9", status: "completed", winner_arm: "treatment" },
            final_config: { rule_version: 5 },
        });
    });

    await setLuckyGiftExperimentOverrides("lalu", "exp-9", {
        removes: [" 1002 ", ""],
        upserts: [{ arm: "control", user_key: "1001" }],
    });
    const ended = await endLuckyGiftExperiment("lalu", "exp-9", "treatment");
    // 获胜方缺失时不允许发请求：结束实验会全量重发配置，不可回滚。
    await expect(endLuckyGiftExperiment("lalu", "exp-9", "")).rejects.toThrow("结束实验必须选择获胜方");

    expect(calls).toEqual([
        {
            body: { removes: ["1002"], upserts: [{ arm: "control", user_key: "1001" }] },
            method: "PUT",
            url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9/overrides?app_code=lalu",
        },
        {
            body: { winner_arm: "treatment" },
            method: "POST",
            url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9/end?app_code=lalu",
        },
    ]);
    expect(ended.experiment).toMatchObject({ status: "completed", winner_arm: "treatment" });
});
