import { describe, expect, it } from "vitest";
import { buildEmailHtml, enrollSubscriber, runScheduler } from "../../src/core.js";
import { DryRunMailer, ScriptedMailer } from "../../src/mailer.js";
import { InMemoryStore } from "../../src/store.js";
import { makeClock, makeConfig } from "../helpers.js";

async function seedBroadcast(
  store: InMemoryStore,
  now: number,
  emails: string[],
  opts: { approved?: boolean; scheduledAt?: number } = {},
) {
  const list = await store.createList("L");
  for (const e of emails) {
    const { subscriber } = await store.upsertSubscriber(e, null, "seed");
    await store.addToList(list.id, subscriber.id);
  }
  const bc = await store.createBroadcast({
    name: "B",
    subject: "件名",
    body_html: "<p>本文</p>",
    list_id: list.id,
  });
  await store.scheduleBroadcast(bc.id, opts.scheduledAt ?? now);
  if (opts.approved) await store.approveBroadcast(bc.id);
  return bc;
}

describe("buildEmailHtml (TC-FR-DELIV-001)", () => {
  it("配信停止リンクと送信者表示を自動付与", () => {
    const cfg = makeConfig({ appBaseUrl: "https://app.test", senderInfo: "送信者X / 住所Y" });
    const html = buildEmailHtml("<p>hi</p>", cfg, "tok123");
    expect(html).toContain("https://app.test/u/tok123");
    expect(html).toContain("送信者X / 住所Y");
    expect(html).toContain("<p>hi</p>");
  });
});

describe("一斉配信スケジューラ", () => {
  it("DRY_RUNでは実送信せず記録のみ・配信はsent扱い (TC-FR-BC-003)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: true });
    const bc = await seedBroadcast(store, clock.now(), ["a@x.com", "b@x.com"], { approved: false });
    const s = await runScheduler(store, mailer, cfg, clock.now());
    expect(mailer.sent.length).toBe(0); // 実送信ゼロ
    expect(s.skipped).toBe(2);
    expect(s.broadcastSends).toBe(0);
    const after = await store.getBroadcast(bc.id);
    expect(after?.status).toBe("sent");
  });

  it("未承認は実送信しない (TC-FR-BC-003 / 誤送信防止)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: false }); // 本番モードでも...
    await seedBroadcast(store, clock.now(), ["a@x.com"], { approved: false }); // 未承認
    const s = await runScheduler(store, mailer, cfg, clock.now());
    expect(mailer.sent.length).toBe(0);
    expect(s.skipped).toBe(1);
  });

  it("承認+本番で送信、二重起動でも1回 (TC-FR-BC-004 / NFR-REL-001)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: false });
    const bc = await seedBroadcast(store, clock.now(), ["a@x.com", "b@x.com", "c@x.com"], { approved: true });

    // 楽観ロック: 連続claimで2回目は空
    const c1 = await store.claimDueBroadcasts(clock.now());
    const c2 = await store.claimDueBroadcasts(clock.now());
    expect(c1.length).toBe(1);
    expect(c2.length).toBe(0);
    // claim済みを sending のまま runScheduler で処理
    await runScheduler(store, mailer, cfg, clock.now());
    expect(mailer.sent.length).toBe(3);
    // 再実行しても再送なし
    await runScheduler(store, mailer, cfg, clock.now());
    expect(mailer.sent.length).toBe(3);
    expect((await store.getBroadcast(bc.id))?.status).toBe("sent");
  });

  it("外部障害で中断→次ティックで未送信分のみ再開 (TC-FR-BC-005 / E2E-04)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new ScriptedMailer([2]); // 2通目で失敗
    const cfg = makeConfig({ dryRun: false });
    const bc = await seedBroadcast(store, clock.now(), ["a@x.com", "b@x.com", "c@x.com"], { approved: true });

    const s1 = await runScheduler(store, mailer, cfg, clock.now());
    expect(s1.broadcastSends).toBe(1); // 1通目のみ成功
    expect(s1.failed).toBe(1); // 2通目失敗→中断
    expect((await store.getBroadcast(bc.id))?.status).toBe("sending"); // 未完了

    const s2 = await runScheduler(store, mailer, cfg, clock.now());
    expect(s2.broadcastSends).toBe(2); // 2・3通目
    expect(mailer.sent.length).toBe(3); // 合計3、1通目の再送なし
    expect((await store.getBroadcast(bc.id))?.status).toBe("sent");
  });

  it("配信停止/バウンスは送信対象外 (TC-FR-BC-007)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: false });
    const list = await store.createList("L");
    const a = (await store.upsertSubscriber("a@x.com", null, "s")).subscriber;
    const b = (await store.upsertSubscriber("b@x.com", null, "s")).subscriber;
    await store.addToList(list.id, a.id);
    await store.addToList(list.id, b.id);
    await store.setSubscriberStatus(b.id, "bounced");
    const bc = await store.createBroadcast({ name: "B", subject: "S", body_html: "x", list_id: list.id });
    await store.scheduleBroadcast(bc.id, clock.now());
    await store.approveBroadcast(bc.id);
    await runScheduler(store, mailer, cfg, clock.now());
    expect(mailer.sent.map((m) => m.msg.to)).toEqual(["a@x.com"]);
  });

  it("ティック送信上限を超えない (TC-FR-SAFE-001 / E2E-06)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: false, maxSendsPerTick: 100 });
    const emails = Array.from({ length: 150 }, (_, i) => `u${i}@x.com`);
    const bc = await seedBroadcast(store, clock.now(), emails, { approved: true });
    const s1 = await runScheduler(store, mailer, cfg, clock.now());
    expect(s1.processed).toBe(100);
    expect(mailer.sent.length).toBe(100);
    expect((await store.getBroadcast(bc.id))?.status).toBe("sending");
    const s2 = await runScheduler(store, mailer, cfg, clock.now());
    expect(s2.processed).toBe(50);
    expect(mailer.sent.length).toBe(150);
    expect((await store.getBroadcast(bc.id))?.status).toBe("sent");
  });

  it("Idempotency-Key を付与する (TC-FR-SAFE-002)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: false });
    const bc = await seedBroadcast(store, clock.now(), ["a@x.com"], { approved: true });
    await runScheduler(store, mailer, cfg, clock.now());
    // 冪等キー形式: bc-<broadcastId>-<subscriberId>
    expect(mailer.sent[0].key).toMatch(new RegExp(`^bc-${bc.id}-\\d+$`));
  });

  it("日次上限を超えない", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: false, maxSendsPerTick: 1000, maxSendsPerDay: 2 });
    const bc = await seedBroadcast(store, clock.now(), ["a@x.com", "b@x.com", "c@x.com"], { approved: true });
    const s1 = await runScheduler(store, mailer, cfg, clock.now());
    expect(s1.broadcastSends).toBe(2); // 日次2まで
    expect((await store.getBroadcast(bc.id))?.status).toBe("sending");
  });
});

describe("ステップメール", () => {
  async function seedCampaign(store: InMemoryStore, now: number, delays: number[]) {
    const list = await store.createList("L");
    const camp = await store.createCampaign({ name: "C", list_id: list.id });
    for (let i = 0; i < delays.length; i++)
      await store.addStepEmail({ campaign_id: camp.id, step_order: i, delay_minutes: delays[i], subject: `S${i}`, body_html: `<p>${i}</p>` });
    const { subscriber } = await store.upsertSubscriber("a@x.com", null, "s");
    await store.addToList(list.id, subscriber.id);
    return { camp, subscriber };
  }

  it("遅延に従い順次送信し、各ステップ1回のみ (TC-FR-STEP-002/003)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: false });
    const { camp, subscriber } = await seedCampaign(store, clock.now(), [0, 60]);
    await enrollSubscriber(store, camp.id, subscriber.id, clock.now());

    await runScheduler(store, mailer, cfg, clock.now()); // step0 (delay0)
    expect(mailer.sent.length).toBe(1);
    // step1 はまだ（60分後）
    await runScheduler(store, mailer, cfg, clock.now());
    expect(mailer.sent.length).toBe(1);

    clock.advanceMinutes(60);
    await runScheduler(store, mailer, cfg, clock.now()); // step1
    expect(mailer.sent.length).toBe(2);
    // 再実行で再送なし & completed
    await runScheduler(store, mailer, cfg, clock.now());
    expect(mailer.sent.length).toBe(2);
    const enr = (await store.listEnrollments(camp.id))[0];
    expect(enr.status).toBe("completed");
  });

  it("重複enrollしない (TC-FR-STEP-004)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const { camp, subscriber } = await seedCampaign(store, clock.now(), [0]);
    const r1 = await enrollSubscriber(store, camp.id, subscriber.id, clock.now());
    const r2 = await enrollSubscriber(store, camp.id, subscriber.id, clock.now());
    expect(r1.created).toBe(true);
    expect(r2.created).toBe(false);
    expect((await store.listEnrollments(camp.id)).length).toBe(1);
  });

  it("配信停止者はステップ進行を停止 (TC-FR-STEP-004)", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new DryRunMailer();
    const cfg = makeConfig({ dryRun: false });
    const { camp, subscriber } = await seedCampaign(store, clock.now(), [0, 60]);
    await enrollSubscriber(store, camp.id, subscriber.id, clock.now());
    await store.setSubscriberStatus(subscriber.id, "unsubscribed");
    await runScheduler(store, mailer, cfg, clock.now());
    expect(mailer.sent.length).toBe(0);
    expect((await store.listEnrollments(camp.id))[0].status).toBe("canceled");
  });

  it("ステップ失敗時は前進せず次ティック再試行", async () => {
    const clock = makeClock();
    const store = new InMemoryStore(clock.now);
    const mailer = new ScriptedMailer([1]); // 最初の送信で失敗
    const cfg = makeConfig({ dryRun: false });
    const { camp, subscriber } = await seedCampaign(store, clock.now(), [0]);
    await enrollSubscriber(store, camp.id, subscriber.id, clock.now());
    const s1 = await runScheduler(store, mailer, cfg, clock.now());
    expect(s1.failed).toBe(1);
    expect((await store.listEnrollments(camp.id))[0].status).toBe("active"); // 前進せず
    const s2 = await runScheduler(store, mailer, cfg, clock.now());
    expect(s2.stepSends).toBe(1);
    expect((await store.listEnrollments(camp.id))[0].status).toBe("completed");
  });
});
