// SMSチャネル（v4）の単体テスト
// 方針は既存と同じ: DryRun送信 + InMemoryStore で純粋ロジックとして検証する。
import { describe, expect, it } from "vitest";
import {
  buildSmsBody,
  canReceiveSms,
  canSendSmsLive,
  isSmsOnlySubscriber,
  runScheduler,
  smsCharCount,
  smsSegments,
  SMS_MAX_CHARS,
} from "../../src/core.js";
import { DryRunMailer } from "../../src/mailer.js";
import {
  DryRunSmsSender,
  normalizeApiBase,
  normalizePhoneE164,
  NttCPaaSSmsSender,
  ScriptedSmsSender,
  toCPaaSDestination,
} from "../../src/sms.js";
import { InMemoryStore } from "../../src/store.js";
import type { RuntimeConfig } from "../../src/types.js";
import { authed, login, makeConfig, setup, T0 } from "../helpers.js";

const PHONE = "+819012345678";

/** SMS実送信できる状態のセットアップ（SMS_DRY_RUN=false + liveな送信アダプタ） */
async function smsSetup(over: Partial<RuntimeConfig> = {}) {
  const store = new InMemoryStore(() => T0);
  const mailer = new DryRunMailer();
  const sms = new ScriptedSmsSender();
  const cfg = makeConfig({ smsDryRun: false, ...over });
  return { store, mailer, sms, cfg };
}

/** 購読者を1人作り、電話番号と同意を設定する */
async function makeSmsSubscriber(
  store: InMemoryStore,
  email = "a@test.example",
  consent = true,
) {
  const { subscriber } = await store.upsertSubscriber(email, null, "test");
  await store.setSubscriberPhone(subscriber.id, PHONE, {
    consent,
    source: "test",
    now: T0,
  });
  return (await store.getSubscriber(subscriber.id))!;
}

/** SMSステップ1本だけのキャンペーンを作り、購読者を登録する */
async function makeSmsCampaign(
  store: InMemoryStore,
  subscriberId: number,
  bodyText = "テスト本文",
) {
  const list = await store.createList("L");
  const camp = await store.createCampaign({ name: "C", list_id: list.id });
  await store.addStepEmail({
    campaign_id: camp.id,
    step_order: 0,
    delay_minutes: 0,
    subject: "SMSステップ",
    body_html: "",
    timing_type: "immediate",
    channel: "sms",
    body_text: bodyText,
  });
  await store.enroll(camp.id, subscriberId, T0, T0);
  return camp;
}

describe("normalizePhoneE164", () => {
  it("国内表記をE.164へ正規化する", () => {
    expect(normalizePhoneE164("090-1234-5678")).toBe(PHONE);
    expect(normalizePhoneE164("09012345678")).toBe(PHONE);
    expect(normalizePhoneE164("090 1234 5678")).toBe(PHONE);
    expect(normalizePhoneE164("(090)1234-5678")).toBe(PHONE);
  });
  it("全角入力も受け付ける", () => {
    expect(normalizePhoneE164("０９０１２３４５６７８")).toBe(PHONE);
    expect(normalizePhoneE164("＋８１９０１２３４５６７８")).toBe(PHONE);
  });
  it("既にE.164ならそのまま", () => {
    expect(normalizePhoneE164("+81 90-1234-5678")).toBe(PHONE);
  });
  it("不正な入力は null", () => {
    expect(normalizePhoneE164("")).toBeNull();
    expect(normalizePhoneE164("abc")).toBeNull();
    expect(normalizePhoneE164("0123")).toBeNull(); // 短すぎ
    expect(normalizePhoneE164("12345678901")).toBeNull(); // 0でも国番号でもない
  });
});

describe("NTT C-Path / NTT CPaaS アダプタ", () => {
  it("ポータル表示のベースURL（スキームなし）に https:// を補う", () => {
    expect(normalizeApiBase("testtenant.ibapi.nttcpaas.com")).toBe("https://testtenant.ibapi.nttcpaas.com");
    expect(normalizeApiBase("https://testtenant.ibapi.nttcpaas.com/")).toBe("https://testtenant.ibapi.nttcpaas.com");
    expect(normalizeApiBase("  testtenant.ibapi.nttcpaas.com  ")).toBe("https://testtenant.ibapi.nttcpaas.com");
  });

  it("宛先はE.164から「+」を外した国際形式にする", () => {
    expect(toCPaaSDestination("+819012345678")).toBe("819012345678");
    expect(toCPaaSDestination("819012345678")).toBe("819012345678");
  });

  it("公式OpenAPI仕様(v3)どおりのリクエストを組み立てる", async () => {
    const calls: { url: string; init: RequestInit }[] = [];
    const orig = globalThis.fetch;
    globalThis.fetch = (async (url: string, init: RequestInit) => {
      calls.push({ url: String(url), init });
      return new Response(
        JSON.stringify({
          bulkId: "b1",
          messages: [{ messageId: "m1", status: { groupName: "PENDING" }, details: { messageCount: 2 } }],
        }),
        { status: 200, headers: { "Content-Type": "application/json" } },
      );
    }) as typeof fetch;
    try {
      const sender = new NttCPaaSSmsSender("KEY123", "testtenant.ibapi.nttcpaas.com");
      const r = await sender.send({ to: PHONE, body: "本文" }, "sms-step-1-2");
      expect(r.ok).toBe(true);
      expect(r.id).toBe("m1:PENDING:2seg:v3"); // 受付状態と課金通数を監査ログに残す
      expect(calls).toHaveLength(1);
      expect(calls[0].url).toBe("https://testtenant.ibapi.nttcpaas.com/sms/3/messages");
      const h = calls[0].init.headers as Record<string, string>;
      expect(h.Authorization).toBe("App KEY123"); // Bearer でも Basic でもない
      expect(h["Content-Type"]).toBe("application/json");
      const body = JSON.parse(String(calls[0].init.body));
      // v3 は sender + content.text（v2 の from + text ではない）
      expect(body.messages[0].sender).toBe("ServiceSMS");
      expect(body.messages[0].content).toEqual({ text: "本文" });
      expect(body.messages[0].from).toBeUndefined();
      expect(body.messages[0].text).toBeUndefined();
      // 宛先は「+」なしの国際形式、冪等キーを messageId に載せる
      expect(body.messages[0].destinations).toEqual([
        { to: "819012345678", messageId: "sms-step-1-2" },
      ]);
      // 課金通数を取得する
      expect(body.options.includeSmsCountInResponse).toBe(true);
    } finally {
      globalThis.fetch = orig;
    }
  });

  it("URL短縮・クリック追跡を必ず無効化する（自前のクリック計測とフィルタ対策）", async () => {
    let sent: any = null;
    const orig = globalThis.fetch;
    globalThis.fetch = (async (_url: string, init: RequestInit) => {
      sent = JSON.parse(String(init.body));
      return new Response("{}", { status: 200 });
    }) as typeof fetch;
    try {
      await new NttCPaaSSmsSender("K", "x.ibapi.nttcpaas.com").send({ to: PHONE, body: "a" }, "k");
      // API側の既定は true。有効のままだと本文URLが業者の短縮URLに書き換えられ、
      // 自前のクリック計測が壊れる＋キャリアの迷惑SMSフィルタに掛かりやすくなる
      expect(sent.options.tracking).toEqual({ shortenUrl: false, trackClicks: false });
    } finally {
      globalThis.fetch = orig;
    }
  });

  it("notifyUrl を指定したときだけ webhooks が入る", async () => {
    const bodies: any[] = [];
    const orig = globalThis.fetch;
    globalThis.fetch = (async (_url: string, init: RequestInit) => {
      bodies.push(JSON.parse(String(init.body)));
      return new Response("{}", { status: 200 });
    }) as typeof fetch;
    try {
      await new NttCPaaSSmsSender("K", "x.ibapi.nttcpaas.com", "ServiceSMS").send(
        { to: PHONE, body: "a" }, "k");
      await new NttCPaaSSmsSender("K", "x.ibapi.nttcpaas.com", "ServiceSMS", "https://app.test/dlr").send(
        { to: PHONE, body: "a" }, "key-1");
      expect(bodies[0].messages[0].webhooks).toBeUndefined();
      expect(bodies[1].messages[0].webhooks.delivery.url).toBe("https://app.test/dlr");
      // DLRと送信を突き合わせるため callbackData に冪等キーを載せる
      expect(bodies[1].messages[0].webhooks.callbackData).toBe("key-1");
    } finally {
      globalThis.fetch = orig;
    }
  });

  it("v3が400なら v2 形式で1度だけ再試行する（公式ドキュメントの不一致対策）", async () => {
    const calls: string[] = [];
    const orig = globalThis.fetch;
    globalThis.fetch = (async (url: string, init: RequestInit) => {
      calls.push(String(url));
      if (String(url).endsWith("/sms/3/messages"))
        return new Response('{"errorCode":"E400","description":"bad"}', { status: 400 });
      // v2 は成功する
      expect(JSON.parse(String(init.body)).messages[0].text).toBe("本文");
      return new Response(JSON.stringify({ messages: [{ messageId: "m2" }] }), { status: 200 });
    }) as typeof fetch;
    try {
      const r = await new NttCPaaSSmsSender("K", "x.ibapi.nttcpaas.com").send(
        { to: PHONE, body: "本文" }, "k");
      expect(r.ok).toBe(true);
      expect(r.id).toContain("v2"); // どちらの形式で通ったかを記録する
      expect(calls).toEqual([
        "https://x.ibapi.nttcpaas.com/sms/3/messages",
        "https://x.ibapi.nttcpaas.com/sms/2/text/advanced",
      ]);
    } finally {
      globalThis.fetch = orig;
    }
  });

  it("400以外のエラーでは再試行しない（二重送信・無駄打ちを避ける）", async () => {
    let calls = 0;
    const orig = globalThis.fetch;
    globalThis.fetch = (async () => { calls++; return new Response("busy", { status: 503 }); }) as typeof fetch;
    try {
      const r = await new NttCPaaSSmsSender("K", "x.ibapi.nttcpaas.com").send(
        { to: PHONE, body: "a" }, "k");
      expect(r.ok).toBe(false);
      expect(calls).toBe(1);
    } finally {
      globalThis.fetch = orig;
    }
  });

  it("エラー応答は ok=false で本文を添えて返す（次ティックで再試行される）", async () => {
    const orig = globalThis.fetch;
    globalThis.fetch = (async () =>
      new Response('{"requestError":{"serviceException":{"text":"Invalid login details"}}}', {
        status: 401,
      })) as typeof fetch;
    try {
      const r = await new NttCPaaSSmsSender("BAD", "x.ibapi.nttcpaas.com").send(
        { to: PHONE, body: "a" }, "k");
      expect(r.ok).toBe(false);
      expect(r.error).toContain("nttcpaas 401");
      expect(r.error).toContain("Invalid login details");
    } finally {
      globalThis.fetch = orig;
    }
  });

  it("通信例外でも落ちずに ok=false を返す", async () => {
    const orig = globalThis.fetch;
    globalThis.fetch = (async () => { throw new Error("network down"); }) as typeof fetch;
    try {
      const r = await new NttCPaaSSmsSender("K", "x.ibapi.nttcpaas.com").send(
        { to: PHONE, body: "a" }, "k");
      expect(r.ok).toBe(false);
      expect(r.error).toContain("network down");
    } finally {
      globalThis.fetch = orig;
    }
  });
});

describe("SMSの文字数・通数（課金単位）", () => {
  it("改行は2文字として数える", () => {
    expect(smsCharCount("abc")).toBe(3);
    expect(smsCharCount("a\nb")).toBe(4);
  });
  it("70文字までは1通、以降は66文字ごとに1通", () => {
    expect(smsSegments("")).toBe(0);
    expect(smsSegments("あ".repeat(70))).toBe(1);
    expect(smsSegments("あ".repeat(71))).toBe(2);
    expect(smsSegments("あ".repeat(136))).toBe(2);
    expect(smsSegments("あ".repeat(137))).toBe(3);
  });
});

describe("buildSmsBody（特定電子メール法対応）", () => {
  const cfg = makeConfig();
  it("送信者表示と配信停止URLが必ず付く", () => {
    const body = buildSmsBody("本文です", cfg, "tok123");
    expect(body).toContain("本文です");
    expect(body).toContain(cfg.senderInfo);
    expect(body).toContain(`${cfg.appBaseUrl}/u/tok123`);
  });
  it("停止URLは自ドメイン（短縮URLサービスを使わない）", () => {
    const body = buildSmsBody("本文", cfg, "tok");
    expect(body).not.toMatch(/bit\.ly|tinyurl|t\.co/);
  });

  it("SMS専用の短い送信者名・短いドメインを設定でき、コストを下げられる", () => {
    // 既定のWorkerドメインは長く、フッターだけで2セグメント消費してしまう
    const long = makeConfig({
      senderInfo: "サンプル株式会社 / 東京都〇〇区1-2-3 / 03-0000-0000",
      appBaseUrl: "https://mail-studio-example-account.workers.dev",
    });
    const short = makeConfig({
      ...long,
      smsSenderInfo: "サンプル株式会社",
      smsBaseUrl: "https://ml.example.jp",
    });
    const text = "お知らせ".repeat(10); // 40文字。セグメントの境界をまたぐ長さで差を見る
    const bodyLong = buildSmsBody(text, long, "x".repeat(32));
    const bodyShort = buildSmsBody(text, short, "x".repeat(32));
    // 自動付与ぶんが短くなり、課金される通数がそのまま減る
    expect(smsCharCount(bodyShort)).toBeLessThan(smsCharCount(bodyLong));
    expect(smsSegments(bodyShort)).toBeLessThan(smsSegments(bodyLong));
    expect(bodyShort).toContain("https://ml.example.jp/u/");
    expect(bodyShort).toContain("サンプル株式会社");
    expect(bodyShort).not.toContain("東京都"); // 住所は停止ページ側で表示する
  });

  it("SMS専用の値が未設定ならメール用の値をそのまま使う（後方互換）", () => {
    const body = buildSmsBody("本文", cfg, "tok");
    expect(body).toContain(cfg.senderInfo);
    expect(body).toContain(`${cfg.appBaseUrl}/u/tok`);
  });
});

describe("送信ゲート", () => {
  it("SMS_DRY_RUN=true なら実送信しない", () => {
    expect(canSendSmsLive(makeConfig({ smsDryRun: true }), new ScriptedSmsSender())).toBe(false);
  });
  it("プロバイダ未設定（DryRunアダプタ）なら実送信しない", () => {
    expect(canSendSmsLive(makeConfig({ smsDryRun: false }), new DryRunSmsSender())).toBe(false);
  });
  it("SMS_DRY_RUN=false かつ live アダプタのときだけ送る", () => {
    expect(canSendSmsLive(makeConfig({ smsDryRun: false }), new ScriptedSmsSender())).toBe(true);
  });
  it("番号があっても同意がなければ送信対象外（オプトイン原則）", async () => {
    const store = new InMemoryStore(() => T0);
    const sub = await makeSmsSubscriber(store, "a@test.example", false);
    expect(sub.sms_status).toBe("none");
    expect(canReceiveSms(sub)).toBe(false);
  });
});

describe("スケジューラ: SMSステップ", () => {
  it("番号＋同意ありなら送信される", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    const sub = await makeSmsSubscriber(store);
    await makeSmsCampaign(store, sub.id, "こんにちは");
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(1);
    expect(sms.sent).toHaveLength(1);
    expect(sms.sent[0].msg.to).toBe(PHONE);
    expect(sms.sent[0].msg.body).toContain("こんにちは");
    expect(sms.sent[0].msg.body).toContain("/u/"); // 停止URLが付く
  });

  it("未同意ならスキップし、シナリオは前進する（詰まらない）", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    const sub = await makeSmsSubscriber(store, "a@test.example", false);
    const camp = await makeSmsCampaign(store, sub.id);
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(0);
    expect(summary.skipped).toBe(1);
    expect(sms.sent).toHaveLength(0);
    const [enr] = await store.listEnrollments(camp.id);
    expect(enr.status).toBe("completed"); // 前進して完了している
  });

  it("電話番号が無い購読者はスキップされる", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    const { subscriber } = await store.upsertSubscriber("nophone@test.example", null, "t");
    await makeSmsCampaign(store, subscriber.id);
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(0);
    expect(summary.skipped).toBe(1);
  });

  it("SMS_DRY_RUN=true では1通も実送信されない", async () => {
    const { store, mailer, sms } = await smsSetup();
    const cfg = makeConfig({ smsDryRun: true });
    const sub = await makeSmsSubscriber(store);
    await makeSmsCampaign(store, sub.id);
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(sms.sent).toHaveLength(0);
    expect(summary.smsSends).toBe(0);
    expect(summary.skipped).toBe(1);
  });

  it("2ティック回しても二重送信しない（冪等）", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    const sub = await makeSmsSubscriber(store);
    await makeSmsCampaign(store, sub.id);
    await runScheduler(store, mailer, cfg, T0, sms);
    await runScheduler(store, mailer, cfg, T0 + 60_000, sms);
    expect(sms.sent).toHaveLength(1);
  });

  it("送信失敗なら前進せず、次ティックで再試行する", async () => {
    const store = new InMemoryStore(() => T0);
    const mailer = new DryRunMailer();
    const sms = new ScriptedSmsSender([1]); // 1回目だけ失敗
    const cfg = makeConfig({ smsDryRun: false });
    const sub = await makeSmsSubscriber(store);
    const camp = await makeSmsCampaign(store, sub.id);

    const s1 = await runScheduler(store, mailer, cfg, T0, sms);
    expect(s1.failed).toBe(1);
    expect(sms.sent).toHaveLength(0);
    let [enr] = await store.listEnrollments(camp.id);
    expect(enr.status).toBe("active"); // 前進していない

    const s2 = await runScheduler(store, mailer, cfg, T0 + 60_000, sms);
    expect(s2.smsSends).toBe(1);
    expect(sms.sent).toHaveLength(1);
    [enr] = await store.listEnrollments(camp.id);
    expect(enr.status).toBe("completed");
  });

  it("上限は「通数」ではなく「セグメント（課金単位）」で消費される", async () => {
    // 自動付与されるフッター（送信者表示＋配信停止URL）だけで70文字を超えるため、
    // 短い本文でも実際には2セグメント課金になる。上限をメッセージ数で数えると
    // 「1日100通=約1,000円」という前提が崩れるので、セグメントで数えていることを固定する。
    const { store, mailer, sms, cfg } = await smsSetup();
    const sub = await makeSmsSubscriber(store);
    await makeSmsCampaign(store, sub.id, "短い本文");
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(1);
    const expected = smsSegments(sms.sent[0].msg.body);
    expect(expected).toBeGreaterThan(1); // フッターだけで1セグメントを超える
    expect(summary.smsSegments).toBe(expected);
    // 日次カウンタもセグメント数で積まれる（＝そのまま概算コストになる）
    expect(await store.getSmsDailySendCount("2023-11-14")).toBe(expected);
  });

  it("MAX_SMS_PER_DAY を超えない（超過分は次の日まで送らない）", async () => {
    const { store, mailer, sms } = await smsSetup();
    // 1通あたり2セグメント消費されるので、上限4セグメント = 2通ぶん
    const cfg = makeConfig({ smsDryRun: false, maxSmsPerDay: 4, maxSmsPerTick: 20 });
    const list = await store.createList("L");
    const camp = await store.createCampaign({ name: "C", list_id: list.id });
    await store.addStepEmail({
      campaign_id: camp.id,
      step_order: 0,
      delay_minutes: 0,
      subject: "S",
      body_html: "",
      timing_type: "immediate",
      channel: "sms",
      body_text: "本文",
    });
    // 3人分の登録を作る（枠に収まるのは2人分）
    for (let i = 0; i < 3; i++) {
      const { subscriber } = await store.upsertSubscriber(`u${i}@test.example`, null, "t");
      await store.setSubscriberPhone(subscriber.id, `+8190000000${i}0`, {
        consent: true,
        source: "test",
        now: T0,
      });
      await store.enroll(camp.id, subscriber.id, T0, T0);
    }
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(2);
    expect(summary.smsSegments).toBeLessThanOrEqual(4);
    expect(sms.sent).toHaveLength(2);
    // 同じ日にもう一度回しても増えない
    await runScheduler(store, mailer, cfg, T0 + 60_000, sms);
    expect(sms.sent).toHaveLength(2);
  });

  it("SMSの枠はメールの枠と独立（メール上限0でもSMSは送れる）", async () => {
    const { store, mailer, sms } = await smsSetup();
    const cfg = makeConfig({ smsDryRun: false, dryRun: false, maxSendsPerDay: 0 });
    const sub = await makeSmsSubscriber(store);
    await makeSmsCampaign(store, sub.id);
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(1);
  });

  it("メール→SMS→メールの混在シナリオが順番どおり進む", async () => {
    const { store, mailer, sms } = await smsSetup();
    const cfg = makeConfig({ smsDryRun: false, dryRun: false });
    const sub = await makeSmsSubscriber(store);
    const list = await store.createList("L");
    const camp = await store.createCampaign({ name: "C", list_id: list.id });
    for (const [order, ch] of [
      [0, "email"],
      [1, "sms"],
      [2, "email"],
    ] as const) {
      await store.addStepEmail({
        campaign_id: camp.id,
        step_order: order,
        delay_minutes: 0,
        subject: `step${order}`,
        body_html: ch === "email" ? `<p>mail${order}</p>` : "",
        timing_type: "immediate",
        channel: ch,
        body_text: ch === "sms" ? "sms本文" : null,
      });
    }
    await store.enroll(camp.id, sub.id, T0, T0);

    await runScheduler(store, mailer, cfg, T0, sms);
    expect(mailer.sent).toHaveLength(1);
    expect(sms.sent).toHaveLength(0);

    await runScheduler(store, mailer, cfg, T0 + 1000, sms);
    expect(sms.sent).toHaveLength(1);

    await runScheduler(store, mailer, cfg, T0 + 2000, sms);
    expect(mailer.sent).toHaveLength(2);
    const [enr] = await store.listEnrollments(camp.id);
    expect(enr.status).toBe("completed");
  });

  it("配信停止（/u/:token）後はSMSも送られない", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    const sub = await makeSmsSubscriber(store);
    await makeSmsCampaign(store, sub.id);
    // 停止処理（app.ts の /u/:token と同じ操作）
    await store.setSubscriberStatus(sub.id, "unsubscribed");
    await store.setSmsStatus(sub.id, "unsubscribed", T0);
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(0);
    expect(sms.sent).toHaveLength(0);
  });
});

describe("SMS一斉配信（単発）", () => {
  /** SMS一斉配信を1件用意し、対象読者を1人リストに入れる */
  async function makeSmsBroadcast(
    store: InMemoryStore,
    opts: { consent?: boolean; approved?: boolean } = {},
  ) {
    const list = await store.createList("L");
    const { subscriber } = await store.upsertSubscriber("a@test.example", null, "t");
    await store.setSubscriberPhone(subscriber.id, PHONE, {
      consent: opts.consent ?? true,
      source: "test",
      now: T0,
    });
    await store.addToList(list.id, subscriber.id);
    const bc = await store.createBroadcast({
      name: "SMS単発",
      subject: "管理用",
      body_html: "",
      list_id: list.id,
      channel: "sms",
      body_text: "本文です",
    });
    if (opts.approved ?? true) await store.approveBroadcast(bc.id);
    await store.scheduleBroadcast(bc.id, T0);
    return { bc, subscriber };
  }

  it("承認済み・同意ありなら送信される", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    const { bc } = await makeSmsBroadcast(store);
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(1);
    expect(sms.sent[0].msg.to).toBe(PHONE);
    expect(sms.sent[0].msg.body).toContain("本文です");
    expect(mailer.sent).toHaveLength(0); // メールは1通も送らない
    expect((await store.getBroadcast(bc.id))!.status).toBe("sent");
  });

  it("未承認なら送らない（メールと同じ承認ゲート）", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    await makeSmsBroadcast(store, { approved: false });
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(0);
    expect(summary.skipped).toBe(1);
    expect(sms.sent).toHaveLength(0);
  });

  it("SMS未同意の読者はスキップされる", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    const { bc } = await makeSmsBroadcast(store, { consent: false });
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(0);
    expect(summary.skipped).toBe(1);
    const sends = await store.listBroadcastSends(bc.id);
    expect(sends[0].status).toBe("skipped");
    expect(sends[0].error).toBe("no_sms_consent");
  });

  it("SMS_DRY_RUN=true では送信されない（メールのDRY_RUNとは独立）", async () => {
    const { store, mailer, sms } = await smsSetup();
    const cfg = makeConfig({ smsDryRun: true, dryRun: false });
    await makeSmsBroadcast(store);
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(0);
    expect(sms.sent).toHaveLength(0);
  });

  it("SMS一斉配信はSMSの枠を消費し、メールの枠を食わない", async () => {
    const { store, mailer, sms } = await smsSetup();
    const cfg = makeConfig({ smsDryRun: false, dryRun: false, maxSendsPerDay: 0 });
    await makeSmsBroadcast(store);
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(1); // メール枠0でもSMSは送れる
    expect(summary.smsSegments).toBeGreaterThan(0);
  });

  it("2ティック回しても二重送信しない", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    await makeSmsBroadcast(store);
    await runScheduler(store, mailer, cfg, T0, sms);
    await runScheduler(store, mailer, cfg, T0 + 60_000, sms);
    expect(sms.sent).toHaveLength(1);
  });
});

describe("公開フォーム: 電話番号の取得", () => {
  /** フォーム用のフックを1つ作る */
  async function makeHook(store: InMemoryStore, withCampaign = false) {
    const list = await store.createList("L");
    let campaignId: number | null = null;
    if (withCampaign) {
      const camp = await store.createCampaign({ name: "C", list_id: list.id, channel: "sms" });
      await store.addStepEmail({
        campaign_id: camp.id,
        step_order: 0,
        delay_minutes: 0,
        subject: "S",
        body_html: "",
        timing_type: "immediate",
        channel: "sms",
        body_text: "登録ありがとうございます",
      });
      campaignId = camp.id;
    }
    return store.createHook({ list_id: list.id, campaign_id: campaignId, name: "テスト登録" });
  }

  it("?sms=1 のときだけ電話番号欄と同意チェックが出る", async () => {
    const { app, store } = setup();
    const hook = await makeHook(store);
    const plain = await (await app.request(`/f/${hook.token}`)).text();
    expect(plain).not.toContain('name="phone"');
    const withSms = await (await app.request(`/f/${hook.token}?sms=1`)).text();
    expect(withSms).toContain('name="phone"');
    expect(withSms).toContain('name="sms_consent"');
    expect(withSms).toContain("停止リンク"); // 注意書きが出る
  });

  it("フォーム送信で電話番号が登録され、同意チェックでSMS対象になる", async () => {
    const { app, store } = setup();
    const hook = await makeHook(store);
    const res = await app.request(`/f/${hook.token}`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: "email=x@test.example&name=テスト&phone=090-1234-5678&sms_consent=on",
    });
    expect(res.status).toBe(200);
    const sub = (await store.listSubscribers())[0];
    expect(sub.phone).toBe(PHONE);
    expect(sub.sms_status).toBe("active");
    expect(sub.sms_consent_at).toBe(T0);
  });

  it("同意チェックなしなら番号は保存されるがSMSは送らない", async () => {
    const { app, store } = setup();
    const hook = await makeHook(store);
    await app.request(`/f/${hook.token}`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: "email=y@test.example&phone=090-1234-5678",
    });
    const sub = (await store.listSubscribers())[0];
    expect(sub.phone).toBe(PHONE);
    expect(sub.sms_status).toBe("none");
  });

  it("不正な電話番号はエラー画面を返し、購読者を作らない", async () => {
    const { app, store } = setup();
    const hook = await makeHook(store);
    const res = await app.request(`/f/${hook.token}`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: "email=z@test.example&phone=abc&sms_consent=on",
    });
    expect(res.status).toBe(400);
    expect(await res.text()).toContain("電話番号の形式");
    expect(await store.listSubscribers()).toHaveLength(0);
  });

  it("フォーム登録でSMSシナリオが自動スタートし、1通目が送られる", async () => {
    const sms = new ScriptedSmsSender();
    const { app, store, mailer, cfg } = setup({
      cfg: { smsDryRun: false },
      smsSender: sms,
    });
    const hook = await makeHook(store, true);
    await app.request(`/f/${hook.token}`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: "email=w@test.example&phone=090-1234-5678&sms_consent=on",
    });
    const summary = await runScheduler(store, mailer, cfg, T0, sms);
    expect(summary.smsSends).toBe(1);
    expect(sms.sent[0].msg.body).toContain("登録ありがとうございます");
  });
});

describe("SMS専用フォーム（電話番号のみ・メール不要）", () => {
  async function makeHook(store: InMemoryStore, withCampaign = false) {
    const list = await store.createList("L");
    let campaignId: number | null = null;
    if (withCampaign) {
      const camp = await store.createCampaign({ name: "C", list_id: list.id, channel: "sms" });
      await store.addStepEmail({
        campaign_id: camp.id,
        step_order: 0,
        delay_minutes: 0,
        subject: "S",
        body_html: "",
        timing_type: "immediate",
        channel: "sms",
        body_text: "SMS専用テスト",
      });
      campaignId = camp.id;
    }
    return store.createHook({ list_id: list.id, campaign_id: campaignId, name: "SMS登録" });
  }

  it("?sms=only ではメール欄を出さず、電話番号だけを聞く", async () => {
    const { app, store } = setup();
    const hook = await makeHook(store);
    const html = await (await app.request(`/f/${hook.token}?sms=only`)).text();
    expect(html).not.toContain('name="email"');
    expect(html).toContain('name="phone"');
    expect(html).toContain('name="sms_consent"');
    // 送信先にもモードが引き継がれる（POST後に同じフォームへ戻せる）
    expect(html).toContain(`action="/f/${hook.token}?sms=only"`);
  });

  it("電話番号だけで登録でき、SMS送信対象になる", async () => {
    const { app, store } = setup();
    const hook = await makeHook(store);
    const res = await app.request(`/f/${hook.token}?sms=only`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: "phone=090-1234-5678&name=テスト&sms_consent=on",
    });
    expect(res.status).toBe(200);
    const subs = await store.listSubscribers();
    expect(subs).toHaveLength(1);
    expect(subs[0].phone).toBe(PHONE);
    expect(subs[0].sms_status).toBe("active");
    // メールアドレスは到達不能なダミー（RFC 2606 の .invalid）
    expect(subs[0].email).toBe("819012345678@sms.invalid");
    expect(isSmsOnlySubscriber(subs[0])).toBe(true);
  });

  it("同意チェックがなければ登録を拒否する（番号だけ集めない）", async () => {
    const { app, store } = setup();
    const hook = await makeHook(store);
    const res = await app.request(`/f/${hook.token}?sms=only`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: "phone=090-1234-5678",
    });
    expect(res.status).toBe(400);
    expect(await res.text()).toContain("同意");
    expect(await store.listSubscribers()).toHaveLength(0);
  });

  it("同じ番号で2回登録しても購読者は増えない（冪等）", async () => {
    const { app, store } = setup();
    const hook = await makeHook(store);
    const body = "phone=09012345678&sms_consent=on";
    const opts = {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body,
    };
    await app.request(`/f/${hook.token}?sms=only`, opts);
    await app.request(`/f/${hook.token}?sms=only`, opts);
    expect(await store.listSubscribers()).toHaveLength(1);
  });

  it("SMS専用読者にはメールを送らず、メールステップは飛ばして前進する", async () => {
    const sms = new ScriptedSmsSender();
    const { app, store, mailer, cfg } = setup({
      cfg: { smsDryRun: false, dryRun: false },
      smsSender: sms,
    });
    const list = await store.createList("L");
    const camp = await store.createCampaign({ name: "C", list_id: list.id, channel: "sms" });
    // step1=メール（SMS専用読者には送れない） → step2=SMS
    await store.addStepEmail({
      campaign_id: camp.id, step_order: 0, delay_minutes: 0,
      subject: "メール", body_html: "<p>hi</p>", timing_type: "immediate", channel: "email",
    });
    await store.addStepEmail({
      campaign_id: camp.id, step_order: 1, delay_minutes: 0,
      subject: "SMS", body_html: "", timing_type: "immediate", channel: "sms",
      body_text: "SMSは届く",
    });
    const hook = await store.createHook({ list_id: list.id, campaign_id: camp.id, name: "h" });
    await app.request(`/f/${hook.token}?sms=only`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: "phone=090-1234-5678&sms_consent=on",
    });

    // 1ティック目: メールステップはスキップされて前進する（詰まらない）
    const s1 = await runScheduler(store, mailer, cfg, T0, sms);
    expect(mailer.sent).toHaveLength(0);
    expect(s1.skipped).toBe(1);
    // 2ティック目: SMSステップが送られる
    const s2 = await runScheduler(store, mailer, cfg, T0 + 1000, sms);
    expect(s2.smsSends).toBe(1);
    expect(sms.sent[0].msg.body).toContain("SMSは届く");
  });

  it("メール一斉配信はSMS専用読者をスキップする", async () => {
    const { store, mailer, sms, cfg } = await smsSetup();
    const list = await store.createList("L");
    const { subscriber } = await store.upsertSubscriberByPhone(PHONE, null, "t", T0);
    await store.addToList(list.id, subscriber.id);
    const bc = await store.createBroadcast({
      name: "メール単発", subject: "件名", body_html: "<p>hi</p>", list_id: list.id,
    });
    await store.approveBroadcast(bc.id);
    await store.scheduleBroadcast(bc.id, T0);
    const summary = await runScheduler(store, mailer, { ...cfg, dryRun: false }, T0, sms);
    expect(mailer.sent).toHaveLength(0);
    expect(summary.skipped).toBe(1);
    const sends = await store.listBroadcastSends(bc.id);
    expect(sends[0].error).toBe("sms_only");
  });
});

describe("API: 電話番号と同意", () => {
  it("sms_consent なしでは送信対象にならない", async () => {
    const { app } = setup();
    const cookie = await login(app);
    const res = await app.request(
      "/api/subscribers",
      authed(cookie, { email: "a@test.example", phone: "090-1234-5678" }),
    );
    const json = (await res.json()) as any;
    expect(json.ok).toBe(true);
    expect(json.subscriber.phone).toBe(PHONE);
    expect(json.subscriber.sms_status).toBe("none");
  });

  it("sms_consent=true なら active になり同意日時が記録される", async () => {
    const { app } = setup();
    const cookie = await login(app);
    const res = await app.request(
      "/api/subscribers",
      authed(cookie, {
        email: "b@test.example",
        phone: "090-1234-5678",
        sms_consent: true,
      }),
    );
    const json = (await res.json()) as any;
    expect(json.subscriber.sms_status).toBe("active");
    expect(json.subscriber.sms_consent_at).toBe(T0);
    expect(json.subscriber.sms_consent_source).toBe("manual");
  });

  it("不正な電話番号は400", async () => {
    const { app } = setup();
    const cookie = await login(app);
    const res = await app.request(
      "/api/subscribers",
      authed(cookie, { email: "c@test.example", phone: "not-a-phone" }),
    );
    expect(res.status).toBe(400);
  });

  it("同じ番号を別の購読者に登録できない", async () => {
    const { app } = setup();
    const cookie = await login(app);
    await app.request(
      "/api/subscribers",
      authed(cookie, { email: "d1@test.example", phone: "090-1111-2222" }),
    );
    const res = await app.request(
      "/api/subscribers",
      authed(cookie, { email: "d2@test.example", phone: "090-1111-2222" }),
    );
    expect(res.status).toBe(400);
  });

  it("番号なしでSMSを有効化しようとすると400", async () => {
    const { app, store } = setup();
    const cookie = await login(app);
    const { subscriber } = await store.upsertSubscriber("e@test.example", null, "t");
    const res = await app.request(
      `/api/subscribers/${subscriber.id}/sms-status`,
      authed(cookie, { status: "active" }),
    );
    expect(res.status).toBe(400);
  });
});

describe("API: SMSステップのCRUD", () => {
  const newCampaign = async (app: ReturnType<typeof setup>["app"], cookie: string) => {
    const l = await app.request("/api/lists", authed(cookie, { name: "L" }));
    const list = (await l.json()) as any;
    const c = await app.request(
      "/api/campaigns",
      authed(cookie, { name: "C", list_id: list.list.id }),
    );
    return ((await c.json()) as any).campaign.id as number;
  };

  it("channel=sms のステップを作成できる", async () => {
    const { app } = setup();
    const cookie = await login(app);
    const cid = await newCampaign(app, cookie);
    const res = await app.request(
      `/api/campaigns/${cid}/steps`,
      authed(cookie, {
        subject: "SMS1",
        channel: "sms",
        body_text: "こんにちは",
        timing_type: "immediate",
      }),
    );
    const json = (await res.json()) as any;
    expect(json.ok).toBe(true);
    expect(json.step.channel).toBe("sms");
    expect(json.step.body_text).toBe("こんにちは");
  });

  it("SMSステップに本文がなければ400", async () => {
    const { app } = setup();
    const cookie = await login(app);
    const cid = await newCampaign(app, cookie);
    const res = await app.request(
      `/api/campaigns/${cid}/steps`,
      authed(cookie, { subject: "S", channel: "sms", timing_type: "immediate" }),
    );
    expect(res.status).toBe(400);
  });

  it("上限文字数を超えるSMS本文は400（フッター込みで判定）", async () => {
    const { app } = setup();
    const cookie = await login(app);
    const cid = await newCampaign(app, cookie);
    const res = await app.request(
      `/api/campaigns/${cid}/steps`,
      authed(cookie, {
        subject: "S",
        channel: "sms",
        body_text: "あ".repeat(SMS_MAX_CHARS),
        timing_type: "immediate",
      }),
    );
    expect(res.status).toBe(400);
  });

  it("メールステップは従来どおり body_html 必須（後方互換）", async () => {
    const { app } = setup();
    const cookie = await login(app);
    const cid = await newCampaign(app, cookie);
    const ng = await app.request(
      `/api/campaigns/${cid}/steps`,
      authed(cookie, { subject: "S", timing_type: "immediate" }),
    );
    expect(ng.status).toBe(400);
    const ok = await app.request(
      `/api/campaigns/${cid}/steps`,
      authed(cookie, { subject: "S", body_html: "<p>hi</p>", timing_type: "immediate" }),
    );
    const json = (await ok.json()) as any;
    expect(json.step.channel).toBe("email");
  });

  it("テスト送信はDRY_RUN中にプレビューと通数を返す", async () => {
    const { app } = setup();
    const cookie = await login(app);
    const cid = await newCampaign(app, cookie);
    const s = await app.request(
      `/api/campaigns/${cid}/steps`,
      authed(cookie, {
        subject: "S",
        channel: "sms",
        body_text: "テスト",
        timing_type: "immediate",
      }),
    );
    const sid = ((await s.json()) as any).step.id;
    const res = await app.request(
      `/api/campaigns/${cid}/steps/${sid}/test`,
      authed(cookie, { phone: "090-1234-5678" }),
    );
    const json = (await res.json()) as any;
    expect(json.ok).toBe(true);
    expect(json.dryRun).toBe(true);
    expect(json.preview).toContain("テスト");
    expect(json.segments).toBeGreaterThanOrEqual(1);
  });
});
