// ポイント & 抽選（クリックブースター相当）のユニットテスト
import { describe, expect, it } from "vitest";
import {
  DEFAULT_POINT_RULES,
  drawPrize,
  parsePointRules,
  rewriteLinks,
  totalProbability,
} from "../../src/points.js";
import type { LotteryPrize } from "../../src/types.js";
import { authed, login, setup } from "../helpers.js";

/** PUT 用の認証済みリクエスト（authed は POST 固定のため） */
function putAuthed(cookie: string, body: unknown): RequestInit {
  return {
    method: "PUT",
    headers: { "Content-Type": "application/json", Cookie: cookie },
    body: JSON.stringify(body),
  };
}

/** 配信 broadcastId を subId 宛に「送信済み(sent)」にする（開封/クリック加点の前提を作る） */
async function markBroadcastSent(
  store: Awaited<ReturnType<typeof setup>>["store"],
  broadcastId: number,
  subId: number,
) {
  await store.ensureBroadcastSends(broadcastId, [subId]);
  const pend = await store.pendingBroadcastSends(broadcastId, 100);
  for (const p of pend)
    if (p.subscriber_id === subId) await store.markBroadcastSend(p.id, "sent", null, null, 0);
}

function prize(id: number, probability: number, max_winners = 0, active = true): LotteryPrize {
  return {
    id,
    lottery_id: 1,
    name: `prize-${id}`,
    description: null,
    probability,
    max_winners,
    active,
    created_at: 0,
  };
}

describe("parsePointRules", () => {
  it("null/不正JSONは既定値", () => {
    expect(parsePointRules(null)).toEqual(DEFAULT_POINT_RULES);
    expect(parsePointRules("not-json")).toEqual(DEFAULT_POINT_RULES);
  });
  it("部分指定は既定値で補完、負値は既定値へ", () => {
    const r = parsePointRules(JSON.stringify({ openPoints: 3, clickPoints: -5 }));
    expect(r.openPoints).toBe(3);
    expect(r.clickPoints).toBe(DEFAULT_POINT_RULES.clickPoints);
    expect(r.signupPoints).toBe(DEFAULT_POINT_RULES.signupPoints);
  });
});

describe("drawPrize", () => {
  const prizes = [prize(1, 10), prize(2, 20)]; // [0,10)=P1, [10,30)=P2, [30,100)=はずれ
  it("乱数が区間内なら当選", () => {
    expect(drawPrize(prizes, new Map(), 0.05)?.id).toBe(1);
    expect(drawPrize(prizes, new Map(), 0.15)?.id).toBe(2);
    expect(drawPrize(prizes, new Map(), 0.299)?.id).toBe(2);
  });
  it("区間外ははずれ", () => {
    expect(drawPrize(prizes, new Map(), 0.3)).toBeNull();
    expect(drawPrize(prizes, new Map(), 0.999)).toBeNull();
  });
  it("当選本数上限に達した景品は当たらない（はずれ扱いではなく区間詰め）", () => {
    const limited = [prize(1, 10, 1), prize(2, 20)];
    const counts = new Map([[1, 1]]); // P1は上限到達
    // P1の区間が消え、[0,20)=P2
    expect(drawPrize(limited, counts, 0.05)?.id).toBe(2);
    expect(drawPrize(limited, counts, 0.25)).toBeNull();
  });
  it("無効景品は対象外", () => {
    expect(drawPrize([prize(1, 100, 0, false)], new Map(), 0.5)).toBeNull();
  });
  it("確率合計の検証", () => {
    expect(totalProbability(prizes)).toBe(30);
  });
});

describe("rewriteLinks", () => {
  it("本文のURLを計測URLへ書き換える（unsubリンクは除外）", async () => {
    const html =
      '<p><a href="https://example.com/lp">LP</a> と <a href="https://app.test/u/tok123">解除</a></p>';
    const seen: string[] = [];
    const out = await rewriteLinks(html, "https://app.test", "SUBTOK", async (url) => {
      seen.push(url);
      return 42;
    });
    expect(out).toContain('href="https://app.test/t/c/42/SUBTOK"');
    expect(out).toContain('href="https://app.test/u/tok123"'); // 解除リンクは温存
    expect(seen).toEqual(["https://example.com/lp"]);
  });
});

describe("計測エンドポイント（開封/クリック）とポイント冪等性", () => {
  it("開封ピクセル: 自分宛の配信なら初回のみ付与、2回目は加算されない", async () => {
    const { app, store } = setup();
    const { subscriber } = await store.upsertSubscriber("a@example.com", null, "test");
    await markBroadcastSent(store, 1, subscriber.id); // 配信1を本人へ送信済みに
    const url = `/t/o/bc/1/${subscriber.unsub_token}`;
    const res1 = await app.request(url);
    expect(res1.status).toBe(200);
    expect(res1.headers.get("Content-Type")).toBe("image/gif");
    await app.request(url);
    expect(await store.pointBalance(subscriber.id)).toBe(DEFAULT_POINT_RULES.openPoints);
  });
  it("クリック: 自分宛の配信なら302転送し初回のみ付与", async () => {
    const { app, store } = setup();
    const { subscriber } = await store.upsertSubscriber("b@example.com", null, "test");
    await markBroadcastSent(store, 1, subscriber.id);
    const link = await store.getOrCreateTrackedLink("bc", 1, "https://example.com/offer");
    const url = `/t/c/${link.id}/${subscriber.unsub_token}`;
    const res = await app.request(url, { redirect: "manual" });
    expect(res.status).toBe(302);
    expect(res.headers.get("Location")).toBe("https://example.com/offer");
    await app.request(url, { redirect: "manual" });
    expect(await store.pointBalance(subscriber.id)).toBe(DEFAULT_POINT_RULES.clickPoints);
  });
  it("【不正防止】送信実績のない配信の開封/クリックは加点されない（refId/linkId総当たり対策）", async () => {
    const { app, store } = setup();
    const { subscriber } = await store.upsertSubscriber("farmer@example.com", null, "test");
    // 本人へは何も送っていない。それでも計測URLは叩ける（画像/302は返る）が加点はされない
    const openRes = await app.request(`/t/o/bc/1/${subscriber.unsub_token}`);
    expect(openRes.status).toBe(200);
    await app.request(`/t/o/bc/2/${subscriber.unsub_token}`);
    await app.request(`/t/o/bc/3/${subscriber.unsub_token}`);
    const link = await store.getOrCreateTrackedLink("bc", 99, "https://example.com/x");
    const clickRes = await app.request(`/t/c/${link.id}/${subscriber.unsub_token}`, { redirect: "manual" });
    expect(clickRes.status).toBe(302); // リンクは壊さない
    expect(await store.pointBalance(subscriber.id)).toBe(0); // だが加点ゼロ
  });
  it("クリックは配信1通につき1回（同一メール内の複数リンクで多重付与しない）", async () => {
    const { app, store } = setup();
    const { subscriber } = await store.upsertSubscriber("multi@example.com", null, "test");
    await markBroadcastSent(store, 5, subscriber.id);
    const l1 = await store.getOrCreateTrackedLink("bc", 5, "https://example.com/a");
    const l2 = await store.getOrCreateTrackedLink("bc", 5, "https://example.com/b");
    await app.request(`/t/c/${l1.id}/${subscriber.unsub_token}`, { redirect: "manual" });
    await app.request(`/t/c/${l2.id}/${subscriber.unsub_token}`, { redirect: "manual" });
    expect(await store.pointBalance(subscriber.id)).toBe(DEFAULT_POINT_RULES.clickPoints);
  });
  it("未送信(pending)の配信では加点されない（送信予定に載っているだけでは対象外）", async () => {
    const { app, store } = setup();
    const { subscriber } = await store.upsertSubscriber("pend@example.com", null, "test");
    await store.ensureBroadcastSends(1, [subscriber.id]); // pending のまま（まだ送信していない）
    await app.request(`/t/o/bc/1/${subscriber.unsub_token}`);
    expect(await store.pointBalance(subscriber.id)).toBe(0);
  });
  it("不正トークンでも画像/404は返る（ポイントは付かない）", async () => {
    const { app } = setup();
    const res = await app.request("/t/o/bc/1/unknown-token");
    expect(res.status).toBe(200);
    const res2 = await app.request("/t/c/999/unknown-token");
    expect(res2.status).toBe(404);
  });
});

describe("抽選フロー（/p/:token）", () => {
  async function arrange(randValue: number) {
    const s = setup();
    // rand固定のためappを作り直す
    const { createApp } = await import("../../src/app.js");
    const app = createApp({
      store: s.store,
      mailer: s.mailer,
      cfg: s.cfg,
      adminPassword: "secret-pass",
      sessionSecret: "unit-test-session-secret-please-long",
      now: s.now,
      rand: () => randValue,
    });
    const { subscriber } = await s.store.upsertSubscriber("c@example.com", "テスト", "test");
    await s.store.addPointEvent(subscriber.id, 300, "manual", null, "テスト用");
    const lottery = await s.store.createLottery({ name: "テスト抽選", description: null, cost_points: 100 });
    const p = await s.store.addPrize({
      lottery_id: lottery.id,
      name: "ギフト券",
      description: null,
      probability: 10,
      max_winners: 1,
    });
    return { app, store: s.store, subscriber, lottery, prizeRow: p };
  }

  it("当選: ポイント消費 + 抽選記録(prize_id) + 残高返却", async () => {
    const { app, store, subscriber, lottery } = await arrange(0.05); // 10%内 → 当選
    const res = await app.request(`/p/${subscriber.unsub_token}/lottery/${lottery.id}/draw`, { method: "POST" });
    const body = (await res.json()) as any;
    expect(body.ok).toBe(true);
    expect(body.win).toBe(true);
    expect(body.prize.name).toBe("ギフト券");
    expect(body.balance).toBe(200);
    const draws = await store.listDraws(lottery.id, 10);
    expect(draws).toHaveLength(1);
    expect(draws[0].prize_id).not.toBeNull();
  });

  it("はずれ: ポイントは消費される", async () => {
    const { app, subscriber, lottery } = await arrange(0.5); // 10%外 → はずれ
    const res = await app.request(`/p/${subscriber.unsub_token}/lottery/${lottery.id}/draw`, { method: "POST" });
    const body = (await res.json()) as any;
    expect(body.ok).toBe(true);
    expect(body.win).toBe(false);
    expect(body.balance).toBe(200);
  });

  it("当選本数上限: 2回目は必ずはずれ", async () => {
    const { app, subscriber, lottery } = await arrange(0.05);
    const r1 = (await (await app.request(`/p/${subscriber.unsub_token}/lottery/${lottery.id}/draw`, { method: "POST" })).json()) as any;
    expect(r1.win).toBe(true);
    const r2 = (await (await app.request(`/p/${subscriber.unsub_token}/lottery/${lottery.id}/draw`, { method: "POST" })).json()) as any;
    expect(r2.win).toBe(false); // max_winners=1 のため同じ乱数でも当たらない
  });

  it("残高不足は400", async () => {
    const { app, store, subscriber, lottery } = await arrange(0.05);
    await store.addPointEvent(subscriber.id, -250, "manual", null, "減算");
    const res = await app.request(`/p/${subscriber.unsub_token}/lottery/${lottery.id}/draw`, { method: "POST" });
    expect(res.status).toBe(400);
  });

  it("マイページHTMLが表示される", async () => {
    const { app, subscriber } = await arrange(0.5);
    const res = await app.request(`/p/${subscriber.unsub_token}`);
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("ポイントマイページ");
    expect(html).toContain("テスト抽選");
  });
});

describe("景品交換フロー", () => {
  it("交換 → 残高減 → 在庫切れで拒否 → キャンセルで返却", async () => {
    const { app, store } = setup();
    const { subscriber } = await store.upsertSubscriber("d@example.com", null, "test");
    await store.addPointEvent(subscriber.id, 1000, "manual", null, "テスト用");
    const item = await store.createExchangeItem({ name: "PDF", description: null, cost_points: 400, stock: 1 });

    const r1 = (await (await app.request(`/p/${subscriber.unsub_token}/exchange/${item.id}`, { method: "POST" })).json()) as any;
    expect(r1.ok).toBe(true);
    expect(r1.balance).toBe(600);

    // 在庫1 → 2回目は在庫切れ
    const res2 = await app.request(`/p/${subscriber.unsub_token}/exchange/${item.id}`, { method: "POST" });
    expect(res2.status).toBe(400);

    // 管理側キャンセル → ポイント返却
    const cookie = await login(app);
    const res3 = await app.request(`/api/exchange/redemptions/${r1.redemptionId}/status`, authed(cookie, { status: "canceled" }));
    expect(res3.status).toBe(200);
    expect(await store.pointBalance(subscriber.id)).toBe(1000);
  });
});

describe("並行リクエストの安全性（レース対策）", () => {
  async function arrangeConcurrent(randValue: number) {
    const s = setup();
    const { createApp } = await import("../../src/app.js");
    const app = createApp({
      store: s.store,
      mailer: s.mailer,
      cfg: s.cfg,
      adminPassword: "secret-pass",
      sessionSecret: "unit-test-session-secret-please-long",
      now: s.now,
      rand: () => randValue,
    });
    return { ...s, app };
  }

  it("抽選: 残高100で100ptくじを同時2回引いても残高はマイナスにならない", async () => {
    const { app, store } = await arrangeConcurrent(0.9); // はずれ想定
    const { subscriber } = await store.upsertSubscriber("race1@example.com", null, "t");
    await store.addPointEvent(subscriber.id, 100, "manual", null, "seed");
    const lottery = await store.createLottery({ name: "L", description: null, cost_points: 100 });
    const url = `/p/${subscriber.unsub_token}/lottery/${lottery.id}/draw`;
    const results = await Promise.all([
      app.request(url, { method: "POST" }),
      app.request(url, { method: "POST" }),
    ]);
    const oks = (await Promise.all(results.map((r) => r.json() as Promise<any>))).filter((x) => x.ok);
    expect(oks).toHaveLength(1); // 1回だけ成功
    expect(await store.pointBalance(subscriber.id)).toBe(0); // 負にならない
  });

  it("交換: 在庫1の景品を同時2回交換しても在庫超過しない・残高も負にならない", async () => {
    const { app, store } = await arrangeConcurrent(0.5);
    const { subscriber } = await store.upsertSubscriber("race2@example.com", null, "t");
    await store.addPointEvent(subscriber.id, 400, "manual", null, "seed");
    const item = await store.createExchangeItem({ name: "PDF", description: null, cost_points: 200, stock: 1 });
    const url = `/p/${subscriber.unsub_token}/exchange/${item.id}`;
    const results = await Promise.all([
      app.request(url, { method: "POST" }),
      app.request(url, { method: "POST" }),
    ]);
    const oks = (await Promise.all(results.map((r) => r.json() as Promise<any>))).filter((x) => x.ok);
    expect(oks).toHaveLength(1); // 在庫1につき1件だけ
    expect(await store.countRedemptions(item.id)).toBe(1);
    expect(await store.pointBalance(subscriber.id)).toBe(200); // 1件分だけ消費
  });

  it("当選本数上限: 同時当選でも上限を超えない", async () => {
    const { app, store } = await arrangeConcurrent(0.05); // 当選域
    const { subscriber } = await store.upsertSubscriber("race3@example.com", null, "t");
    await store.addPointEvent(subscriber.id, 1000, "manual", null, "seed");
    const lottery = await store.createLottery({ name: "L", description: null, cost_points: 100 });
    await store.addPrize({ lottery_id: lottery.id, name: "限定賞", description: null, probability: 100, max_winners: 1 });
    const url = `/p/${subscriber.unsub_token}/lottery/${lottery.id}/draw`;
    await Promise.all([
      app.request(url, { method: "POST" }),
      app.request(url, { method: "POST" }),
      app.request(url, { method: "POST" }),
    ]);
    const draws = await store.listDraws(lottery.id, 10);
    const wins = draws.filter((d) => d.prize_id !== null);
    expect(wins).toHaveLength(1); // max_winners=1 を超えない
  });

  it("抽選: 配信停止(unsubscribed)の読者は参加できない", async () => {
    const { app, store } = await arrangeConcurrent(0.5);
    const { subscriber } = await store.upsertSubscriber("unsub@example.com", null, "t");
    await store.addPointEvent(subscriber.id, 500, "manual", null, "seed");
    await store.setSubscriberStatus(subscriber.id, "unsubscribed");
    const lottery = await store.createLottery({ name: "L", description: null, cost_points: 100 });
    const res = await app.request(`/p/${subscriber.unsub_token}/lottery/${lottery.id}/draw`, { method: "POST" });
    expect(res.status).toBe(400);
    expect(await store.pointBalance(subscriber.id)).toBe(500); // 消費されない
  });
});

describe("管理APIの入力検証", () => {
  it("景品編集で確率合計100%超はすり抜けられない（PUT /api/prizes/:id）", async () => {
    const { app, store } = setup();
    const cookie = await login(app);
    const lottery = await store.createLottery({ name: "L", description: null, cost_points: 100 });
    const p1 = await store.addPrize({ lottery_id: lottery.id, name: "A", description: null, probability: 60, max_winners: 0 });
    await store.addPrize({ lottery_id: lottery.id, name: "B", description: null, probability: 30, max_winners: 0 });
    // A を 60→80 に上げると 80+30=110 で超過 → 400
    const res = await app.request(`/api/prizes/${p1.id}`, putAuthed(cookie, { probability: 80 }));
    expect(res.status).toBe(400);
    // 巨大確率も拒否
    const res2 = await app.request(`/api/prizes/${p1.id}`, putAuthed(cookie, { probability: 500 }));
    expect(res2.status).toBe(400);
    // 元の値は保持されている
    expect((await store.getPrize(p1.id))?.probability).toBe(60);
  });

  it("くじ・景品の消費ptをマイナスにできない（PUT）", async () => {
    const { app, store } = setup();
    const cookie = await login(app);
    const lottery = await store.createLottery({ name: "L", description: null, cost_points: 100 });
    const rl = await app.request(`/api/lotteries/${lottery.id}`, putAuthed(cookie, { cost_points: -50 }));
    expect(rl.status).toBe(400);
    const item = await store.createExchangeItem({ name: "X", description: null, cost_points: 100, stock: -1 });
    const ri = await app.request(`/api/exchange/items/${item.id}`, putAuthed(cookie, { cost_points: -50 }));
    expect(ri.status).toBe(400);
  });

  it("手動ポイント調整は残高を下回るマイナスにできない", async () => {
    const { app, store } = setup();
    const cookie = await login(app);
    const { subscriber } = await store.upsertSubscriber("grant@example.com", null, "t");
    await store.addPointEvent(subscriber.id, 50, "manual", null, "seed");
    const res = await app.request(`/api/points/grant`, authed(cookie, { subscriberId: subscriber.id, points: -100 }));
    expect(res.status).toBe(400);
    expect(await store.pointBalance(subscriber.id)).toBe(50); // 負にならない
  });
});

describe("交換キャンセルの状態遷移", () => {
  it("キャンセル(返金)後に対応済みへ戻せない・二重返金しない", async () => {
    const { app, store } = setup();
    const cookie = await login(app);
    const { subscriber } = await store.upsertSubscriber("cx@example.com", null, "t");
    await store.addPointEvent(subscriber.id, 1000, "manual", null, "seed");
    const item = await store.createExchangeItem({ name: "PDF", description: null, cost_points: 400, stock: 5 });
    const r1 = (await (await app.request(`/p/${subscriber.unsub_token}/exchange/${item.id}`, { method: "POST" })).json()) as any;
    expect(await store.pointBalance(subscriber.id)).toBe(600);
    // キャンセル → 400pt返金 → 残高1000
    await app.request(`/api/exchange/redemptions/${r1.redemptionId}/status`, authed(cookie, { status: "canceled" }));
    expect(await store.pointBalance(subscriber.id)).toBe(1000);
    // canceled から done へ戻そうとすると拒否（返金保持＋景品受領の抜け穴を防ぐ）
    const back = await app.request(`/api/exchange/redemptions/${r1.redemptionId}/status`, authed(cookie, { status: "done" }));
    expect(back.status).toBe(400);
    // もう一度キャンセルを打っても二重返金されない
    await app.request(`/api/exchange/redemptions/${r1.redemptionId}/status`, authed(cookie, { status: "canceled" }));
    expect(await store.pointBalance(subscriber.id)).toBe(1000);
  });
});

describe("登録ボーナス", () => {
  it("フック経由の新規登録で signup ポイントが付く（再登録では付かない）", async () => {
    const { app, store } = setup();
    const list = await store.createList("L");
    const hook = await store.createHook({ list_id: list.id, campaign_id: null, name: null });
    await app.request(`/api/hooks/${hook.token}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email: "new@example.com" }),
    });
    const sub = (await store.listSubscribers())[0];
    expect(await store.pointBalance(sub.id)).toBe(DEFAULT_POINT_RULES.signupPoints);
    // 同じメールで再POST → 加算されない
    await app.request(`/api/hooks/${hook.token}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email: "new@example.com" }),
    });
    expect(await store.pointBalance(sub.id)).toBe(DEFAULT_POINT_RULES.signupPoints);
  });
});
