// Hono アプリ（管理API + 公開エンドポイント）。Store/Mailer を注入しテスト可能。
import { Hono } from "hono";
import {
  checkPassword,
  clearCookie,
  createSession,
  readCookie,
  SESSION_COOKIE,
  sessionCookie,
  verifySession,
} from "./auth.js";
import {
  buildEmailHtml,
  buildSmsBody,
  canSendSmsLive,
  enrollSubscriber,
  runScheduler,
  smsCharCount,
  smsSegments,
  SMS_MAX_CHARS,
} from "./core.js";
import type { Mailer } from "./mailer.js";
import { drawPrize, parsePointRules, totalProbability } from "./points.js";
import { portalPage, type PortalData } from "./portal.js";
import { DryRunSmsSender, normalizePhoneE164, type SmsSender } from "./sms.js";
import type { Store } from "./store.js";
import type {
  ExchangeItem,
  ExchangeRedemption,
  LotteryDraw,
  LotteryPrize,
  PointRules,
  RuntimeConfig,
  SmsStatus,
  StepChannel,
  StepTimingType,
  SubscriberStatus,
} from "./types.js";
import { dayKey, escapeHtml, isValidEmail, normalizeEmail } from "./util.js";

export interface AppDeps {
  store: Store;
  mailer: Mailer;
  /** SMS送信アダプタ（省略時は DryRun = 実送信しない） */
  smsSender?: SmsSender;
  cfg: RuntimeConfig;
  adminPassword: string;
  sessionSecret: string;
  resendWebhookSecret?: string;
  now?: () => number;
  /** 抽選用乱数 [0,1)（テストで固定可能） */
  rand?: () => number;
}

const POINT_RULES_KEY = "point_rules";

// 1x1 透明GIF（開封計測ピクセル）
const PIXEL_GIF = Uint8Array.from(
  atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
  (c) => c.charCodeAt(0),
);

export function createApp(deps: AppDeps) {
  const { store, mailer, cfg } = deps;
  const smsSender = deps.smsSender ?? new DryRunSmsSender();
  const now = deps.now ?? (() => Date.now());
  const rand = deps.rand ?? Math.random;
  const app = new Hono();

  const pointRules = async (): Promise<PointRules> =>
    parsePointRules(await store.getSetting(POINT_RULES_KEY));

  // ---------- 認証（ガード対象外） ----------
  app.post("/api/login", async (c) => {
    const body = await c.req
      .json<{ password?: string }>()
      .catch(() => ({}) as { password?: string });
    if (!checkPassword(body.password ?? "", deps.adminPassword))
      return c.json({ ok: false, error: "invalid password" }, 401);
    const token = await createSession(deps.sessionSecret, now());
    c.header("Set-Cookie", sessionCookie(token));
    await store.audit("login", "admin");
    return c.json({ ok: true });
  });

  app.post("/api/logout", (c) => {
    c.header("Set-Cookie", clearCookie());
    return c.json({ ok: true });
  });

  app.get("/api/me", async (c) => {
    const token = readCookie(c.req.header("Cookie") ?? null, SESSION_COOKIE);
    const ok = await verifySession(deps.sessionSecret, token, now());
    return c.json({ authenticated: ok, dryRun: cfg.dryRun });
  });

  // ---------- 公開: 外部連携（Webhook / フォーム） ----------
  const intake = async (
    token: string,
    email: string,
    name: string | null,
    source: string,
    /** v5: 電話番号とSMS受信同意（フォーム/Webhookから任意で受け取る） */
    sms?: { phone?: string | null; consent?: boolean },
  ) => {
    const hook = await store.getHook(token);
    if (!hook) return { status: 404 as const, error: "hook not found" };
    const e = normalizeEmail(email ?? "");
    if (!isValidEmail(e)) return { status: 400 as const, error: "invalid email" };
    // 電話番号は購読者を作る前に検証する（不正な番号で購読者だけ増えるのを防ぐ）
    const rawPhone = sms?.phone == null ? "" : String(sms.phone).trim();
    let phone: string | null = null;
    if (rawPhone !== "") {
      phone = normalizePhoneE164(rawPhone);
      if (!phone) return { status: 400 as const, error: "invalid phone" };
    }
    const { subscriber, created } = await store.upsertSubscriber(e, name, source);
    await store.addToList(hook.list_id, subscriber.id);
    if (phone) {
      // 同意チェックが入っているときだけSMS送信対象にする（特電法のオプトイン原則）
      const r = await store.setSubscriberPhone(subscriber.id, phone, {
        consent: sms?.consent === true,
        source,
        now: now(),
      });
      if (!r.ok) return { status: 400 as const, error: r.error ?? "phone update failed" };
    }
    // 新規登録ボーナス（初回のみ・冪等）
    if (created) {
      const rules = await pointRules();
      if (rules.signupPoints > 0)
        await store.addPointEvent(
          subscriber.id,
          rules.signupPoints,
          "signup",
          `signup:sub:${subscriber.id}`,
          "登録ボーナス",
        );
    }
    let enrolled = false;
    if (hook.campaign_id) {
      const r = await enrollSubscriber(store, hook.campaign_id, subscriber.id, now());
      enrolled = r.created;
    }
    await store.audit("intake", `hook=${token} email=${e} created=${created}`);
    return { status: 200 as const, subscriberId: subscriber.id, created, enrolled };
  };

  /**
   * 電話番号だけで登録する（SMS専用）。メールアドレスは聞かない。
   * SMSは同意がないと1通も送れないため、このモードでは同意を必須にする
   * （同意なしで番号だけ集めても使い道がなく、個人情報を無駄に持つことになる）。
   */
  const intakeByPhone = async (
    token: string,
    rawPhone: string,
    name: string | null,
    source: string,
    consent: boolean,
  ) => {
    const hook = await store.getHook(token);
    if (!hook) return { status: 404 as const, error: "hook not found" };
    const phone = normalizePhoneE164(String(rawPhone ?? "").trim());
    if (!phone) return { status: 400 as const, error: "invalid phone" };
    if (!consent) return { status: 400 as const, error: "consent required" };
    const { subscriber, created } = await store.upsertSubscriberByPhone(
      phone,
      name,
      source,
      now(),
    );
    await store.addToList(hook.list_id, subscriber.id);
    const r = await store.setSubscriberPhone(subscriber.id, phone, {
      consent: true,
      source,
      now: now(),
    });
    if (!r.ok) return { status: 400 as const, error: r.error ?? "phone update failed" };
    if (created) {
      const rules = await pointRules();
      if (rules.signupPoints > 0)
        await store.addPointEvent(
          subscriber.id,
          rules.signupPoints,
          "signup",
          `signup:sub:${subscriber.id}`,
          "登録ボーナス",
        );
    }
    let enrolled = false;
    if (hook.campaign_id) {
      const e = await enrollSubscriber(store, hook.campaign_id, subscriber.id, now());
      enrolled = e.created;
    }
    await store.audit("intake-sms", `hook=${token} sub=${subscriber.id} created=${created}`);
    return { status: 200 as const, subscriberId: subscriber.id, created, enrolled };
  };

  // 外部LP（LPHarness等）の独自フォームから fetch で直接叩けるよう CORS を許可
  // （トークンを知る者のみ登録できる公開エンドポイントのため Origin 制限は不要）
  const HOOK_CORS: Record<string, string> = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
  };
  app.options("/api/hooks/:token", (c) => c.body(null, 204, HOOK_CORS));
  app.post("/api/hooks/:token", async (c) => {
    const token = c.req.param("token");
    type HookBody = {
      email?: string;
      name?: string;
      phone?: string;
      sms_consent?: boolean;
    };
    const body = await c.req.json<HookBody>().catch(() => ({}) as HookBody);
    const r = await intake(token, body.email ?? "", body.name ?? null, "webhook", {
      phone: body.phone,
      consent: body.sms_consent === true,
    });
    if (r.status !== 200)
      return c.json({ ok: false, error: r.error }, r.status, HOOK_CORS);
    return c.json({ ok: true, created: r.created, enrolled: r.enrolled }, 200, HOOK_CORS);
  });

  /**
   * 公開フォームの3モード:
   *   （なし）      メールのみ
   *   ?sms=1       メール＋電話番号（両方）
   *   ?sms=only    電話番号のみ（メールアドレスを聞かない = SMS専用登録）
   */
  const formMode = (q: string | undefined): FormMode =>
    q === "only" ? "sms_only" : q === "1" ? "email_sms" : "email";

  app.get("/f/:token", async (c) => {
    const token = c.req.param("token");
    const hook = await store.getHook(token);
    if (!hook) return c.html(formPage("リンクが無効です", "", true), 404);
    const mode = formMode(c.req.query("sms"));
    return c.html(
      formPage(hook.name ?? (mode === "sms_only" ? "SMS登録" : "メール登録"), token, false, mode),
    );
  });

  app.post("/f/:token", async (c) => {
    const token = c.req.param("token");
    const form = await c.req.parseBody().catch(() => ({}) as Record<string, string>);
    const f = form as Record<string, unknown>;
    const email = String(f.email ?? "");
    const name = f.name ? String(f.name) : null;
    const phone = f.phone ? String(f.phone) : null;
    const mode = formMode(c.req.query("sms"));
    // チェックボックスは on / 未送信 で届く
    const consent =
      f.sms_consent === "on" || f.sms_consent === "1" || f.sms_consent === "true";
    const r =
      mode === "sms_only"
        ? await intakeByPhone(token, phone ?? "", name, "form-sms", consent)
        : await intake(token, email, name, "form", { phone, consent });
    if (r.status === 404) return c.html(formPage("リンクが無効です", "", true), 404);
    if (r.status === 400)
      return c.html(
        formPage(
          r.error === "invalid phone"
            ? "電話番号の形式が正しくありません（例: 090-1234-5678）"
            : r.error === "consent required"
              ? "SMSを送るには受信の同意が必要です"
              : "メールアドレスの形式が正しくありません",
          token,
          false,
          mode,
          true,
        ),
        400,
      );
    return c.html(thanksPage("ご登録ありがとうございました。", cfg.senderInfo));
  });

  app.get("/u/:token", async (c) => {
    const token = c.req.param("token");
    const sub = await store.getSubscriberByToken(token);
    if (!sub) return c.html(thanksPage("リンクが無効です。"), 404);
    await store.setSubscriberStatus(sub.id, "unsubscribed");
    // SMSも同時に停止する（1つの停止リンクで全チャネルを止める）
    await store.setSmsStatus(sub.id, "unsubscribed", now());
    await store.audit("unsubscribe", `sub=${sub.id} (email+sms)`);
    // SMS本文には文字数の都合で短い送信者名しか載せられないため、
    // 特定電子メール法が求める送信者情報（住所等を含む）はこのページで表示する。
    return c.html(
      thanksPage("配信を停止しました（メール・SMSとも停止しました）。", cfg.senderInfo),
    );
  });

  // ---------- 公開: SNSハーネス連携アダプター ----------

  /**
   * LINE Harness outgoing webhook / automation `send_webhook` 受信。
   * LINE Harness は { event, timestamp, data } 形式で POST する。
   * data 内に email がある場合（form_submitted のフィールドなど）に intake へ中継。
   * HMAC署名検証対応（LINE Harness 側の webhook secret と照合）。
   */
  app.post("/api/integrations/line/:token", async (c) => {
    const token = c.req.param("token");
    const body = await c.req
      .json<{
        event?: string;
        timestamp?: number;
        data?: {
          friendId?: string;
          email?: string;
          fields?: Record<string, string>;
          eventData?: { displayName?: string };
          [key: string]: unknown;
        };
      }>()
      .catch(
        () =>
          ({}) as {
            event?: string;
            data?: {
              friendId?: string;
              email?: string;
              fields?: Record<string, string>;
              eventData?: { displayName?: string };
            };
          },
      );

    const event = body.event ?? "";
    const data = body.data ?? {};

    // メールアドレスの解決優先順位:
    // 1. data.email（直接指定）
    // 2. data.fields.email / data.fields.メール（フォーム回答）
    // 3. なければ 400（メール不明 → 登録不可）
    const email =
      data.email ??
      data.fields?.email ??
      data.fields?.["メール"] ??
      data.fields?.["メールアドレス"] ??
      "";
    const name =
      data.eventData?.displayName ??
      data.fields?.name ??
      data.fields?.["名前"] ??
      data.fields?.["お名前"] ??
      null;

    if (!email) {
      await store.audit(
        "line_harness",
        `event=${event} token=${token} no_email (friendId=${data.friendId ?? "?"})`,
      );
      return c.json(
        {
          ok: false,
          error: "email not found in payload. Use LINE form to collect email first.",
          hint: "Set data.email or data.fields.email in the outgoing webhook / automation payload.",
        },
        400,
      );
    }

    const source = `line:${event || "webhook"}`;
    const r = await intake(token, email, name, source);
    if (r.status !== 200) return c.json({ ok: false, error: r.error }, r.status);
    await store.audit(
      "line_harness",
      `event=${event} email=${email} created=${r.created} enrolled=${r.enrolled} friendId=${data.friendId ?? "?"}`,
    );
    return c.json({ ok: true, created: r.created, enrolled: r.enrolled, source });
  });

  /**
   * X Harness 連携受信。
   * engagement gate クリア / step sequence 完了時に X Harness から POST される。
   * { event, xUserId, xUsername, email?, name? } 形式を想定。
   */
  app.post("/api/integrations/x/:token", async (c) => {
    const token = c.req.param("token");
    const body = await c.req
      .json<{
        event?: string;
        xUserId?: string;
        xUsername?: string;
        email?: string;
        name?: string;
      }>()
      .catch(
        () =>
          ({}) as {
            event?: string;
            xUserId?: string;
            xUsername?: string;
            email?: string;
            name?: string;
          },
      );

    const email = body.email ?? "";
    const name = body.name ?? body.xUsername ?? null;

    if (!email) {
      await store.audit(
        "x_harness",
        `event=${body.event ?? "?"} token=${token} no_email (xUser=${body.xUsername ?? "?"})`,
      );
      return c.json(
        {
          ok: false,
          error: "email required. Collect email via form before triggering mail enrollment.",
        },
        400,
      );
    }

    const source = `x:${body.event || "webhook"}`;
    const r = await intake(token, email, name, source);
    if (r.status !== 200) return c.json({ ok: false, error: r.error }, r.status);
    await store.audit(
      "x_harness",
      `event=${body.event ?? "?"} email=${email} created=${r.created} enrolled=${r.enrolled} xUser=${body.xUsername ?? "?"}`,
    );
    return c.json({ ok: true, created: r.created, enrolled: r.enrolled, source });
  });

  app.post("/api/resend/webhook", async (c) => {
    // 簡易署名検証（共有シークレット）。完全な Svix 検証は DEFER。
    if (deps.resendWebhookSecret) {
      const got = c.req.header("x-webhook-secret");
      if (got !== deps.resendWebhookSecret)
        return c.json({ ok: false, error: "bad signature" }, 401);
    }
    const body = await c.req
      .json<{ type?: string; data?: { email?: string; to?: string | string[] } }>()
      .catch(
        () => ({}) as { type?: string; data?: { email?: string; to?: string | string[] } },
      );
    const type = body.type ?? "";
    const to = body.data?.email ?? body.data?.to;
    const email = Array.isArray(to) ? to[0] : to;
    if (email) {
      if (type === "email.bounced")
        await store.setSubscriberStatusByEmail(email, "bounced");
      else if (type === "email.complained")
        await store.setSubscriberStatusByEmail(email, "unsubscribed");
      await store.audit("resend_webhook", `${type} ${email}`);
    }
    return c.json({ ok: true });
  });

  // ---------- 公開: ポイント計測（開封ピクセル / クリックリダイレクト） ----------

  /** 開封計測: メール本文末尾の 1x1 GIF。初回のみポイント付与（冪等） */
  app.get("/t/o/:scope/:refId/:token", async (c) => {
    const scope = c.req.param("scope");
    const refId = Number(c.req.param("refId"));
    const token = c.req.param("token");
    if ((scope === "bc" || scope === "step") && Number.isFinite(refId)) {
      const sub = await store.getSubscriberByToken(token);
      if (sub && sub.status === "active") {
        // その配信が実際にこの購読者へ送られた場合のみ加点（refId総当たりのfarming防止）
        const wasSent =
          scope === "bc"
            ? await store.wasBroadcastSentTo(refId, sub.id)
            : await store.wasStepSentTo(refId, sub.id);
        const rules = await pointRules();
        if (wasSent && rules.openPoints > 0) {
          const r = await store.addPointEvent(
            sub.id,
            rules.openPoints,
            "open",
            `open:${scope}:${refId}:sub:${sub.id}`,
            "メール開封",
          );
          if (r.created) await store.audit("point_open", `sub=${sub.id} ${scope}:${refId} +${rules.openPoints}`);
        }
      }
    }
    return new Response(PIXEL_GIF, {
      headers: {
        "Content-Type": "image/gif",
        "Cache-Control": "no-store, no-cache, must-revalidate",
      },
    });
  });

  /** クリック計測: 計測リンク経由で元URLへ302。初回のみポイント付与（冪等） */
  app.get("/t/c/:linkId/:token", async (c) => {
    const linkId = Number(c.req.param("linkId"));
    const token = c.req.param("token");
    const link = Number.isFinite(linkId) ? await store.getTrackedLink(linkId) : null;
    if (!link) return c.html(thanksPage("リンクが無効です。"), 404);
    const sub = await store.getSubscriberByToken(token);
    if (sub && sub.status === "active") {
      // そのリンクを含む配信がこの購読者へ送られた場合のみ加点（linkId総当たりのfarming防止）
      const wasSent =
        link.scope === "bc"
          ? await store.wasBroadcastSentTo(link.ref_id, sub.id)
          : await store.wasStepSentTo(link.ref_id, sub.id);
      const rules = await pointRules();
      if (wasSent && rules.clickPoints > 0) {
        // 1配信につき1回だけ加点（同一メール内の複数リンクで多重付与しない）
        const r = await store.addPointEvent(
          sub.id,
          rules.clickPoints,
          "click",
          `click:${link.scope}:${link.ref_id}:sub:${sub.id}`,
          "リンククリック",
        );
        if (r.created) await store.audit("point_click", `sub=${sub.id} ${link.scope}:${link.ref_id} +${rules.clickPoints}`);
      }
    }
    return c.redirect(link.url, 302);
  });

  // ---------- 公開: 読者マイページ（ポイント / 抽選会 / 景品交換） ----------

  const loadPortalData = async (token: string): Promise<PortalData | null> => {
    const sub = await store.getSubscriberByToken(token);
    if (!sub) return null;
    const [balance, events, lotteriesRaw, itemsRaw, drawsAll, redemptionsAll] = await Promise.all([
      store.pointBalance(sub.id),
      store.listPointEvents(sub.id, 30),
      store.listLotteries(),
      store.listExchangeItems(),
      store.listDraws(null, 500),
      store.listRedemptions(500),
    ]);
    const lotteries = [];
    for (const l of lotteriesRaw.filter((x) => x.status === "open"))
      lotteries.push({ ...l, prizes: await store.listPrizes(l.id) });
    const items = [];
    for (const it of itemsRaw.filter((x) => x.active)) {
      const remaining = it.stock < 0 ? null : it.stock - (await store.countRedemptions(it.id));
      items.push({ ...it, remaining });
    }
    const lotteryName = new Map(lotteriesRaw.map((l) => [l.id, l.name]));
    const prizeName = new Map<number, string>();
    for (const l of lotteriesRaw)
      for (const p of await store.listPrizes(l.id)) prizeName.set(p.id, p.name);
    const itemName = new Map(itemsRaw.map((i) => [i.id, i.name]));
    return {
      subscriber: sub,
      balance,
      events,
      lotteries,
      items,
      myDraws: drawsAll
        .filter((d) => d.subscriber_id === sub.id)
        .map((d) => ({
          ...d,
          lottery_name: lotteryName.get(d.lottery_id) ?? "",
          prize_name: d.prize_id !== null ? (prizeName.get(d.prize_id) ?? null) : null,
        })),
      myRedemptions: redemptionsAll
        .filter((r) => r.subscriber_id === sub.id)
        .map((r) => ({ ...r, item_name: itemName.get(r.item_id) ?? "" })),
    };
  };

  app.get("/p/:token", async (c) => {
    const token = c.req.param("token");
    const data = await loadPortalData(token);
    if (!data) return c.html(thanksPage("リンクが無効です。"), 404);
    return c.html(portalPage(data, token));
  });

  /** くじを引く（残高チェック → 消費 → 抽選 → 記録） */
  app.post("/p/:token/lottery/:id/draw", async (c) => {
    const token = c.req.param("token");
    const lotteryId = Number(c.req.param("id"));
    const sub = await store.getSubscriberByToken(token);
    if (!sub) return c.json({ ok: false, error: "リンクが無効です" }, 404);
    if (sub.status !== "active")
      return c.json({ ok: false, error: "配信停止中のため参加できません" }, 400);
    const lottery = await store.getLottery(lotteryId);
    if (!lottery || lottery.status !== "open")
      return c.json({ ok: false, error: "この抽選会は終了しました" }, 400);
    // 残高判定と消費を原子的に（同時実行・連打でも負残高にならない）
    const spent = await store.trySpendPoints(sub.id, lottery.cost_points, "lottery", `くじ「${lottery.name}」`);
    if (!spent.ok) {
      const balance = await store.pointBalance(sub.id);
      return c.json({ ok: false, error: `ポイントが足りません（あと ${lottery.cost_points - balance} pt）` }, 400);
    }
    let outcome: { draw: LotteryDraw; won: boolean };
    let prize: LotteryPrize | null = null;
    try {
      const prizes = (await store.listPrizes(lotteryId)).filter((p) => p.active);
      const winCounts = await store.prizeWinCounts(lotteryId);
      prize = drawPrize(prizes, winCounts, rand());
      // 当選本数上限を原子的に判定して記録（同時当選で上限を超えない。超えた場合ははずれに）
      outcome = await store.recordDrawCapped(
        lotteryId,
        sub.id,
        prize ? { id: prize.id, max_winners: prize.max_winners } : null,
        lottery.cost_points,
      );
    } catch (e) {
      // 記録に失敗したら消費ポイントを返却（ptだけ失われるのを防ぐ）
      await store.addPointEvent(sub.id, lottery.cost_points, "lottery", null, `くじ「${lottery.name}」中断返却`);
      await store.audit("lottery_draw_error", `sub=${sub.id} lottery=${lotteryId} ${String(e)}`);
      return c.json({ ok: false, error: "抽選処理に失敗しました。ポイントは返却されました" }, 500);
    }
    const won = outcome.won;
    const wonPrize = won ? prize : null;
    await store.audit(
      "lottery_draw",
      `sub=${sub.id} lottery=${lotteryId} result=${wonPrize ? `win:${wonPrize.name}` : "miss"}`,
    );
    const newBalance = await store.pointBalance(sub.id);
    return c.json({
      ok: true,
      win: won,
      prize: wonPrize ? { id: wonPrize.id, name: wonPrize.name, description: wonPrize.description } : null,
      balance: newBalance,
    });
  });

  /** 景品交換（残高・在庫チェック → 消費 → 申請記録） */
  app.post("/p/:token/exchange/:id", async (c) => {
    const token = c.req.param("token");
    const itemId = Number(c.req.param("id"));
    const sub = await store.getSubscriberByToken(token);
    if (!sub) return c.json({ ok: false, error: "リンクが無効です" }, 404);
    if (sub.status !== "active")
      return c.json({ ok: false, error: "配信停止中のため交換できません" }, 400);
    const item = await store.getExchangeItem(itemId);
    if (!item || !item.active) return c.json({ ok: false, error: "この景品は交換できません" }, 400);
    // 先に残高判定と消費を原子的に（同時実行でも負残高にならない）
    const spent = await store.trySpendPoints(sub.id, item.cost_points, "exchange", `交換「${item.name}」`);
    if (!spent.ok) {
      const balance = await store.pointBalance(sub.id);
      return c.json({ ok: false, error: `ポイントが足りません（あと ${item.cost_points - balance} pt）` }, 400);
    }
    // 在庫を原子的に確保（同時交換で在庫超過しない）。確保できなければ消費を返却
    let redemption: ExchangeRedemption | null;
    try {
      redemption = await store.tryReserveRedemption(item, sub.id);
    } catch (e) {
      await store.addPointEvent(sub.id, item.cost_points, "exchange", null, `交換「${item.name}」中断返却`);
      await store.audit("exchange_error", `sub=${sub.id} item=${item.id} ${String(e)}`);
      return c.json({ ok: false, error: "交換処理に失敗しました。ポイントは返却されました" }, 500);
    }
    if (!redemption) {
      await store.addPointEvent(sub.id, item.cost_points, "exchange", null, `交換「${item.name}」在庫切れ返却`);
      return c.json({ ok: false, error: "在庫切れです" }, 400);
    }
    await store.audit("exchange", `sub=${sub.id} item=${item.id} (${item.name})`);
    return c.json({ ok: true, redemptionId: redemption.id, balance: await store.pointBalance(sub.id) });
  });

  // ---------- ガード ----------
  app.use("/api/*", async (c, next) => {
    const token = readCookie(c.req.header("Cookie") ?? null, SESSION_COOKIE);
    if (!(await verifySession(deps.sessionSecret, token, now())))
      return c.json({ ok: false, error: "unauthorized" }, 401);
    await next();
  });

  // ---------- 管理: 購読者 / リスト ----------
  app.get("/api/subscribers", async (c) => c.json(await store.listSubscribers()));
  app.post("/api/subscribers", async (c) => {
    const b = await c.req.json<{
      email?: string;
      name?: string;
      listId?: number;
      phone?: string;
      sms_consent?: boolean;
    }>();
    const e = normalizeEmail(b.email ?? "");
    if (!isValidEmail(e)) return c.json({ ok: false, error: "invalid email" }, 400);
    // 電話番号は先に検証してから購読者を作る（不正番号で購読者だけ作られるのを防ぐ）
    let phone: string | null = null;
    if (b.phone !== undefined && b.phone !== null && String(b.phone).trim() !== "") {
      phone = normalizePhoneE164(String(b.phone));
      if (!phone) return c.json({ ok: false, error: "invalid phone" }, 400);
    }
    const { subscriber, created } = await store.upsertSubscriber(e, b.name ?? null, "manual");
    if (b.listId) await store.addToList(b.listId, subscriber.id);
    if (phone) {
      // sms_consent が明示的に true のときだけ送信対象にする（特電法のオプトイン原則）
      const r = await store.setSubscriberPhone(subscriber.id, phone, {
        consent: b.sms_consent === true,
        source: "manual",
        now: now(),
      });
      if (!r.ok) return c.json({ ok: false, error: r.error ?? "phone update failed" }, 400);
    }
    return c.json({
      ok: true,
      subscriber: (await store.getSubscriber(subscriber.id)) ?? subscriber,
      created,
    });
  });
  app.post("/api/subscribers/:id/status", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req.json<{ status?: SubscriberStatus }>();
    const valid: SubscriberStatus[] = ["active", "unsubscribed", "bounced"];
    if (!b.status || !valid.includes(b.status))
      return c.json({ ok: false, error: "invalid status" }, 400);
    await store.setSubscriberStatus(id, b.status);
    return c.json({ ok: true });
  });
  /** 電話番号の登録・変更・削除（phone=null で削除）。同意は明示指定のときだけ立つ */
  app.post("/api/subscribers/:id/phone", async (c) => {
    const id = Number(c.req.param("id"));
    if (!(await store.getSubscriber(id)))
      return c.json({ ok: false, error: "not found" }, 404);
    const b = await c.req.json<{ phone?: string | null; sms_consent?: boolean }>();
    const raw = b.phone === undefined || b.phone === null ? "" : String(b.phone).trim();
    let phone: string | null = null;
    if (raw !== "") {
      phone = normalizePhoneE164(raw);
      if (!phone) return c.json({ ok: false, error: "invalid phone" }, 400);
    }
    const r = await store.setSubscriberPhone(id, phone, {
      consent: b.sms_consent === true,
      source: "manual",
      now: now(),
    });
    if (!r.ok) return c.json({ ok: false, error: r.error ?? "phone update failed" }, 400);
    await store.audit("sms-phone-update", `sub=${id} consent=${b.sms_consent === true}`);
    return c.json({ ok: true, subscriber: await store.getSubscriber(id) });
  });
  /** SMSの配信可否のみ変更（メールの status には影響しない） */
  app.post("/api/subscribers/:id/sms-status", async (c) => {
    const id = Number(c.req.param("id"));
    const sub = await store.getSubscriber(id);
    if (!sub) return c.json({ ok: false, error: "not found" }, 404);
    const b = await c.req.json<{ status?: SmsStatus }>();
    const valid: SmsStatus[] = ["none", "active", "unsubscribed"];
    if (!b.status || !valid.includes(b.status))
      return c.json({ ok: false, error: "invalid sms status" }, 400);
    if (b.status === "active" && !sub.phone)
      return c.json({ ok: false, error: "phone required before enabling sms" }, 400);
    await store.setSmsStatus(id, b.status, now());
    await store.audit("sms-status", `sub=${id} status=${b.status}`);
    return c.json({ ok: true, subscriber: await store.getSubscriber(id) });
  });

  app.get("/api/lists", async (c) => c.json(await store.listLists()));
  app.post("/api/lists", async (c) => {
    const b = await c.req.json<{ name?: string }>();
    if (!b.name?.trim()) return c.json({ ok: false, error: "name required" }, 400);
    return c.json({ ok: true, list: await store.createList(b.name.trim()) });
  });
  app.get("/api/lists/:id/subscribers", async (c) =>
    c.json(await store.subscribersInList(Number(c.req.param("id")))),
  );
  app.post("/api/lists/:id/subscribers", async (c) => {
    const listId = Number(c.req.param("id"));
    const b = await c.req.json<{ subscriberId?: number }>();
    if (!b.subscriberId) return c.json({ ok: false, error: "subscriberId required" }, 400);
    await store.addToList(listId, b.subscriberId);
    return c.json({ ok: true });
  });
  app.delete("/api/lists/:id/subscribers/:sid", async (c) => {
    await store.removeFromList(Number(c.req.param("id")), Number(c.req.param("sid")));
    return c.json({ ok: true });
  });

  // ---------- 管理: 一斉配信 ----------
  app.get("/api/broadcasts", async (c) => c.json(await store.listBroadcasts()));
  app.post("/api/broadcasts", async (c) => {
    const b = await c.req.json<{
      name?: string;
      subject?: string;
      body_html?: string;
      list_id?: number;
      channel?: string;
      body_text?: string;
    }>();
    const channel: StepChannel = b.channel === "sms" ? "sms" : "email";
    // subject は SMS でも「管理画面上の配信名の補助」として使うため必須のまま
    if (!b.name || !b.subject || !b.list_id)
      return c.json({ ok: false, error: "name/subject/list_id required" }, 400);
    let bodyText: string | null = null;
    if (channel === "sms") {
      const v = validateSmsBody(b.body_text ?? "");
      if (!v.ok) return c.json({ ok: false, error: v.error }, 400);
      bodyText = b.body_text as string;
    } else if (!b.body_html) {
      return c.json({ ok: false, error: "body_html required" }, 400);
    }
    const bc = await store.createBroadcast({
      name: b.name,
      subject: b.subject,
      body_html: channel === "sms" ? "" : (b.body_html as string),
      list_id: b.list_id,
      channel,
      body_text: bodyText,
    });
    return c.json({ ok: true, broadcast: bc });
  });
  app.get("/api/broadcasts/:id", async (c) => {
    const id = Number(c.req.param("id"));
    const bc = await store.getBroadcast(id);
    if (!bc) return c.json({ ok: false, error: "not found" }, 404);
    const sends = await store.listBroadcastSends(id);
    return c.json({ broadcast: bc, sends });
  });
  app.post("/api/broadcasts/:id/schedule", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req
      .json<{ scheduled_at?: number }>()
      .catch(() => ({}) as { scheduled_at?: number });
    const at = b.scheduled_at ?? now(); // 省略時は即時（now の予約）
    const ok = await store.scheduleBroadcast(id, at);
    if (!ok) return c.json({ ok: false, error: "cannot schedule (status)" }, 400);
    await store.audit("schedule", `bc=${id} at=${at}`);
    return c.json({ ok: true, scheduled_at: at });
  });
  app.post("/api/broadcasts/:id/approve", async (c) => {
    const id = Number(c.req.param("id"));
    await store.approveBroadcast(id);
    await store.audit("approve", `bc=${id}`);
    return c.json({ ok: true });
  });
  app.post("/api/broadcasts/:id/cancel", async (c) => {
    const id = Number(c.req.param("id"));
    const ok = await store.cancelBroadcast(id);
    if (!ok)
      return c.json({ ok: false, error: "cancel only allowed before sending" }, 400);
    await store.audit("cancel", `bc=${id}`);
    return c.json({ ok: true });
  });

  // ---------- 管理: ステップメール ----------
  // 一覧には「メール何通 / SMS何通」を添えて返す（画面のチャネル別振り分けと混在バッジ用）
  app.get("/api/campaigns", async (c) => {
    const [camps, counts] = await Promise.all([
      store.listCampaigns(),
      store.campaignChannelCounts(),
    ]);
    return c.json(
      camps.map((camp) => ({
        ...camp,
        channels: counts.get(camp.id) ?? { email: 0, sms: 0 },
      })),
    );
  });
  app.post("/api/campaigns", async (c) => {
    const b = await c.req.json<{ name?: string; list_id?: number; channel?: string }>();
    if (!b.name || !b.list_id)
      return c.json({ ok: false, error: "name/list_id required" }, 400);
    return c.json({
      ok: true,
      campaign: await store.createCampaign({
        name: b.name,
        list_id: b.list_id,
        channel: b.channel === "sms" ? "sms" : "email",
      }),
    });
  });
  app.get("/api/campaigns/:id", async (c) => {
    const id = Number(c.req.param("id"));
    const camp = await store.getCampaign(id);
    if (!camp) return c.json({ ok: false, error: "not found" }, 404);
    const sendCounts = Object.fromEntries(await store.countStepSends(id));
    return c.json({
      campaign: camp,
      steps: await store.listStepEmails(id),
      enrollments: await store.listEnrollments(id),
      sendCounts,
    });
  });
  app.put("/api/campaigns/:id", async (c) => {
    const id = Number(c.req.param("id"));
    if (!(await store.getCampaign(id)))
      return c.json({ ok: false, error: "not found" }, 404);
    const b = await c.req.json<{ name?: string; status?: string }>();
    const patch: { name?: string; status?: "active" | "paused" } = {};
    if (b.name !== undefined) {
      if (!b.name.trim()) return c.json({ ok: false, error: "name empty" }, 400);
      patch.name = b.name.trim();
    }
    if (b.status !== undefined) {
      if (b.status !== "active" && b.status !== "paused")
        return c.json({ ok: false, error: "status must be active|paused" }, 400);
      patch.status = b.status;
    }
    await store.updateCampaign(id, patch);
    await store.audit("campaign-update", `campaign=${id} ${JSON.stringify(b)}`);
    return c.json({ ok: true, campaign: await store.getCampaign(id) });
  });

  /** 配信タイミング入力の検証・正規化（immediate | relative | day_time） */
  const parseTiming = (b: {
    timing_type?: string;
    delay_minutes?: number;
    delay_days?: number;
    send_time?: string | null;
  }):
    | {
        ok: true;
        timing_type: StepTimingType;
        delay_minutes: number;
        delay_days: number;
        send_time: string | null;
      }
    | { ok: false; error: string } => {
    const t = (b.timing_type ?? "relative") as StepTimingType;
    if (t !== "immediate" && t !== "relative" && t !== "day_time")
      return { ok: false, error: "timing_type must be immediate|relative|day_time" };
    if (t === "day_time") {
      if (!b.send_time || !/^\d{1,2}:\d{2}$/.test(b.send_time))
        return { ok: false, error: "send_time (HH:MM) required for day_time" };
      const [h, m] = b.send_time.split(":").map(Number);
      if (h > 23 || m > 59) return { ok: false, error: "invalid send_time" };
      const days = Math.max(0, Math.floor(b.delay_days ?? 0));
      return { ok: true, timing_type: t, delay_minutes: 0, delay_days: days, send_time: b.send_time };
    }
    const mins = t === "immediate" ? 0 : Math.max(0, Math.floor(b.delay_minutes ?? 0));
    return { ok: true, timing_type: t, delay_minutes: mins, delay_days: 0, send_time: null };
  };

  /**
   * SMS本文の検証。法定フッター（送信者表示＋配信停止URL）は送信時に自動付与されるため、
   * その分を含めた実際の送信サイズで上限判定する（保存後に上限超過で送れない事故を防ぐ）。
   */
  const validateSmsBody = (
    bodyText: string,
  ): { ok: true; chars: number; segments: number } | { ok: false; error: string } => {
    if (!bodyText.trim()) return { ok: false, error: "body_text required for sms" };
    const full = buildSmsBody(bodyText, cfg, "x".repeat(32)); // トークン長を仮置きして概算
    const chars = smsCharCount(full);
    if (chars > SMS_MAX_CHARS)
      return {
        ok: false,
        error: `sms too long: ${chars} chars (max ${SMS_MAX_CHARS} incl. footer)`,
      };
    return { ok: true, chars, segments: smsSegments(full) };
  };

  app.post("/api/campaigns/:id/steps", async (c) => {
    const id = Number(c.req.param("id"));
    if (!(await store.getCampaign(id)))
      return c.json({ ok: false, error: "not found" }, 404);
    const b = await c.req.json<{
      step_order?: number;
      delay_minutes?: number;
      subject?: string;
      body_html?: string;
      timing_type?: string;
      delay_days?: number;
      send_time?: string | null;
      channel?: string;
      body_text?: string;
    }>();
    const channel: StepChannel = b.channel === "sms" ? "sms" : "email";
    // subject は SMS でも「管理画面上のステップ名」として使うため必須
    if (!b.subject) return c.json({ ok: false, error: "subject required" }, 400);
    let bodyText: string | null = null;
    if (channel === "sms") {
      const v = validateSmsBody(b.body_text ?? "");
      if (!v.ok) return c.json({ ok: false, error: v.error }, 400);
      bodyText = b.body_text as string;
    } else if (!b.body_html) {
      return c.json({ ok: false, error: "body_html required" }, 400);
    }
    const timing = parseTiming(b);
    if (!timing.ok) return c.json({ ok: false, error: timing.error }, 400);
    // step_order 未指定なら末尾に自動採番（手入力を不要に）
    const steps = await store.listStepEmails(id);
    const order =
      b.step_order ?? (steps.length ? steps[steps.length - 1].step_order + 1 : 0);
    try {
      const step = await store.addStepEmail({
        campaign_id: id,
        step_order: order,
        delay_minutes: timing.delay_minutes,
        subject: b.subject,
        body_html: channel === "sms" ? "" : (b.body_html as string),
        timing_type: timing.timing_type,
        delay_days: timing.delay_days,
        send_time: timing.send_time,
        channel,
        body_text: bodyText,
      });
      return c.json({ ok: true, step });
    } catch {
      return c.json({ ok: false, error: "duplicate step_order" }, 400);
    }
  });
  app.put("/api/campaigns/:id/steps/:sid", async (c) => {
    const id = Number(c.req.param("id"));
    const sid = Number(c.req.param("sid"));
    const step = await store.getStepEmail(sid);
    if (!step || step.campaign_id !== id)
      return c.json({ ok: false, error: "not found" }, 404);
    const b = await c.req.json<{
      subject?: string;
      body_html?: string;
      timing_type?: string;
      delay_minutes?: number;
      delay_days?: number;
      send_time?: string | null;
      channel?: string;
      body_text?: string;
    }>();
    const patch: Parameters<Store["updateStepEmail"]>[1] = {};
    // 変更後のチャネル（未指定なら現状維持）
    const nextChannel: StepChannel =
      b.channel === undefined
        ? ((step.channel ?? "email") as StepChannel)
        : b.channel === "sms"
          ? "sms"
          : "email";
    if (b.channel !== undefined) patch.channel = nextChannel;
    if (b.subject !== undefined) {
      if (!b.subject.trim()) return c.json({ ok: false, error: "subject empty" }, 400);
      patch.subject = b.subject;
    }
    if (b.body_text !== undefined) {
      const v = validateSmsBody(b.body_text);
      if (!v.ok) return c.json({ ok: false, error: v.error }, 400);
      patch.body_text = b.body_text;
    }
    if (b.body_html !== undefined) {
      if (!b.body_html && nextChannel === "email")
        return c.json({ ok: false, error: "body_html empty" }, 400);
      patch.body_html = b.body_html;
    }
    // SMSへ切り替えるなら本文（既存 or 今回指定）が必ず要る
    if (nextChannel === "sms" && !(patch.body_text ?? step.body_text))
      return c.json({ ok: false, error: "body_text required for sms" }, 400);
    if (nextChannel === "email" && !(patch.body_html ?? step.body_html))
      return c.json({ ok: false, error: "body_html required for email" }, 400);
    if (b.timing_type !== undefined) {
      const timing = parseTiming(b);
      if (!timing.ok) return c.json({ ok: false, error: timing.error }, 400);
      patch.timing_type = timing.timing_type;
      patch.delay_minutes = timing.delay_minutes;
      patch.delay_days = timing.delay_days;
      patch.send_time = timing.send_time;
    }
    await store.updateStepEmail(sid, patch);
    await store.audit("step-update", `campaign=${id} step=${sid}`);
    return c.json({ ok: true, step: await store.getStepEmail(sid) });
  });
  app.delete("/api/campaigns/:id/steps/:sid", async (c) => {
    const id = Number(c.req.param("id"));
    const sid = Number(c.req.param("sid"));
    const step = await store.getStepEmail(sid);
    if (!step || step.campaign_id !== id)
      return c.json({ ok: false, error: "not found" }, 404);
    await store.deleteStepEmail(sid);
    await store.audit("step-delete", `campaign=${id} step=${sid}`);
    return c.json({ ok: true });
  });
  app.post("/api/campaigns/:id/steps/reorder", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req.json<{ ids?: number[] }>();
    const steps = await store.listStepEmails(id);
    const ids = b.ids ?? [];
    const current = steps.map((s) => s.id);
    if (
      ids.length !== current.length ||
      [...ids].sort((x, y) => x - y).join(",") !==
        [...current].sort((x, y) => x - y).join(",")
    )
      return c.json({ ok: false, error: "ids must be a permutation of current steps" }, 400);
    await store.reorderStepEmails(id, ids);
    await store.audit("step-reorder", `campaign=${id} order=${ids.join(">")}`);
    return c.json({ ok: true, steps: await store.listStepEmails(id) });
  });
  // テスト送信: 指定アドレス/番号へこのステップを1通だけ送る（DRY_RUN中は送信されず記録のみ）
  app.post("/api/campaigns/:id/steps/:sid/test", async (c) => {
    const id = Number(c.req.param("id"));
    const sid = Number(c.req.param("sid"));
    const step = await store.getStepEmail(sid);
    if (!step || step.campaign_id !== id)
      return c.json({ ok: false, error: "not found" }, 404);
    // --- SMSステップ: 指定番号へ1通（本文プレビューと通数も返す） ---
    if ((step.channel ?? "email") === "sms") {
      const sb = await c.req.json<{ phone?: string }>().catch(() => ({}) as { phone?: string });
      const phone = normalizePhoneE164(String(sb.phone ?? ""));
      if (!phone) return c.json({ ok: false, error: "valid phone required" }, 400);
      const body = buildSmsBody(step.body_text ?? "", cfg, "test-token");
      const live = canSendSmsLive(cfg, smsSender);
      if (!live) {
        await store.audit("step-test-sms", `step=${sid} to=${phone} live=false`);
        return c.json({
          ok: true,
          dryRun: true,
          preview: body,
          chars: smsCharCount(body),
          segments: smsSegments(body),
        });
      }
      const r = await smsSender.send({ to: phone, body }, `test-sms-${sid}-${now()}`);
      await store.audit("step-test-sms", `step=${sid} to=${phone} live=true ok=${r.ok}`);
      if (!r.ok) return c.json({ ok: false, error: r.error ?? "sms send error" }, 502);
      return c.json({
        ok: true,
        dryRun: false,
        preview: body,
        chars: smsCharCount(body),
        segments: smsSegments(body),
      });
    }
    const b = await c.req.json<{ email?: string }>();
    if (!b.email || !isValidEmail(b.email))
      return c.json({ ok: false, error: "valid email required" }, 400);
    const { subscriber } = await store.upsertSubscriber(
      normalizeEmail(b.email),
      null,
      "test-send",
    );
    const html = buildEmailHtml(step.body_html, cfg, subscriber.unsub_token);
    const r = await mailer.send(
      { to: subscriber.email, subject: `[テスト] ${step.subject}`, html },
      cfg.mailFrom,
      `test-${sid}-${now()}`,
    );
    await store.audit(
      "step-test-send",
      `step=${sid} to=${subscriber.email} live=${mailer.live && !cfg.dryRun} ok=${r.ok}`,
    );
    if (!r.ok) return c.json({ ok: false, error: r.error ?? "send error" }, 502);
    return c.json({ ok: true, dryRun: !(mailer.live && !cfg.dryRun) });
  });
  app.post("/api/campaigns/:id/enroll", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req.json<{ subscriberId?: number; email?: string }>();
    let subId = b.subscriberId;
    if (!subId && b.email) {
      const { subscriber } = await store.upsertSubscriber(
        normalizeEmail(b.email),
        null,
        "manual-enroll",
      );
      subId = subscriber.id;
    }
    if (!subId) return c.json({ ok: false, error: "subscriberId or email required" }, 400);
    const r = await enrollSubscriber(store, id, subId, now());
    return c.json({ ok: true, created: r.created });
  });

  // ---------- 管理: 連携リンク ----------
  app.get("/api/hooks", async (c) => c.json(await store.listHooks()));
  app.post("/api/hooks", async (c) => {
    const b = await c.req.json<{
      list_id?: number;
      campaign_id?: number | null;
      name?: string;
    }>();
    if (!b.list_id) return c.json({ ok: false, error: "list_id required" }, 400);
    const hook = await store.createHook({
      list_id: b.list_id,
      campaign_id: b.campaign_id ?? null,
      name: b.name ?? null,
    });
    return c.json({ ok: true, hook });
  });

  // ---------- 管理: ポイント ----------
  app.get("/api/points/rules", async (c) => c.json(await pointRules()));
  app.put("/api/points/rules", async (c) => {
    const b = await c.req.json<Partial<PointRules>>().catch(() => ({}) as Partial<PointRules>);
    const cur = await pointRules();
    const n = (v: unknown, d: number) =>
      typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : d;
    const next: PointRules = {
      openPoints: n(b.openPoints, cur.openPoints),
      clickPoints: n(b.clickPoints, cur.clickPoints),
      signupPoints: n(b.signupPoints, cur.signupPoints),
    };
    await store.setSetting(POINT_RULES_KEY, JSON.stringify(next));
    await store.audit("point_rules", JSON.stringify(next));
    return c.json({ ok: true, rules: next });
  });
  app.get("/api/points/balances", async (c) => {
    const [balances, subs] = await Promise.all([store.listPointBalances(), store.listSubscribers()]);
    const subMap = new Map(subs.map((s) => [s.id, s]));
    return c.json(
      balances.map((b) => ({
        ...b,
        email: subMap.get(b.subscriber_id)?.email ?? "?",
        name: subMap.get(b.subscriber_id)?.name ?? null,
      })),
    );
  });
  app.get("/api/points/events/:subscriberId", async (c) =>
    c.json(await store.listPointEvents(Number(c.req.param("subscriberId")), 100)),
  );
  app.post("/api/points/grant", async (c) => {
    const b = await c.req.json<{ subscriberId?: number; points?: number; note?: string }>();
    if (!b.subscriberId || typeof b.points !== "number" || !Number.isFinite(b.points) || b.points === 0)
      return c.json({ ok: false, error: "subscriberId / points(±) required" }, 400);
    const sub = await store.getSubscriber(b.subscriberId);
    if (!sub) return c.json({ ok: false, error: "subscriber not found" }, 404);
    const delta = Math.trunc(b.points);
    if (delta < 0) {
      // マイナス調整は残高を下回れない（原子的に消費）
      const spent = await store.trySpendPoints(sub.id, -delta, "manual", b.note ?? "運営調整");
      if (!spent.ok)
        return c.json({ ok: false, error: "残高が不足しています（マイナスにはできません）" }, 400);
    } else {
      await store.addPointEvent(sub.id, delta, "manual", null, b.note ?? "運営調整");
    }
    await store.audit("point_grant", `sub=${sub.id} ${delta} ${b.note ?? ""}`);
    return c.json({ ok: true, balance: await store.pointBalance(sub.id) });
  });

  // ---------- 管理: 抽選会 ----------
  app.get("/api/lotteries", async (c) => c.json(await store.listLotteries()));
  app.post("/api/lotteries", async (c) => {
    const b = await c.req.json<{ name?: string; description?: string; cost_points?: number }>();
    if (!b.name?.trim()) return c.json({ ok: false, error: "name required" }, 400);
    if (b.cost_points !== undefined && (!Number.isFinite(b.cost_points) || b.cost_points < 1))
      return c.json({ ok: false, error: "cost_points は1以上の数値にしてください" }, 400);
    const cost = b.cost_points ? Math.max(1, Math.floor(b.cost_points)) : 100;
    const lottery = await store.createLottery({
      name: b.name.trim(),
      description: b.description?.trim() || null,
      cost_points: cost,
    });
    await store.audit("lottery_create", `id=${lottery.id} ${lottery.name}`);
    return c.json({ ok: true, lottery });
  });
  app.get("/api/lotteries/:id", async (c) => {
    const id = Number(c.req.param("id"));
    const lottery = await store.getLottery(id);
    if (!lottery) return c.json({ ok: false, error: "not found" }, 404);
    const prizes = await store.listPrizes(id);
    const winCounts = await store.prizeWinCounts(id);
    const draws = await store.listDraws(id, 100);
    return c.json({
      lottery,
      prizes: prizes.map((p) => ({ ...p, winners: winCounts.get(p.id) ?? 0 })),
      totalProbability: totalProbability(prizes),
      draws,
    });
  });
  app.put("/api/lotteries/:id", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req.json<{
      name?: string;
      description?: string;
      cost_points?: number;
      status?: "open" | "closed";
    }>();
    if (b.status && b.status !== "open" && b.status !== "closed")
      return c.json({ ok: false, error: "invalid status" }, 400);
    const patch: Partial<{ name: string; description: string | null; cost_points: number; status: "open" | "closed" }> = {};
    if (b.name !== undefined) {
      if (!b.name.trim()) return c.json({ ok: false, error: "name は空にできません" }, 400);
      patch.name = b.name.trim();
    }
    if (b.description !== undefined) patch.description = b.description?.trim() || null;
    if (b.cost_points !== undefined) {
      if (!Number.isFinite(b.cost_points) || b.cost_points < 1)
        return c.json({ ok: false, error: "cost_points は1以上の数値にしてください" }, 400);
      patch.cost_points = Math.floor(b.cost_points);
    }
    if (b.status !== undefined) patch.status = b.status;
    await store.updateLottery(id, patch);
    return c.json({ ok: true });
  });
  app.post("/api/lotteries/:id/prizes", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req.json<{
      name?: string;
      description?: string;
      probability?: number;
      max_winners?: number;
    }>();
    if (!b.name?.trim() || typeof b.probability !== "number" || !Number.isFinite(b.probability) || b.probability <= 0 || b.probability > 100)
      return c.json({ ok: false, error: "name / probability(0<p<=100) required" }, 400);
    if (b.max_winners !== undefined && (!Number.isInteger(b.max_winners) || b.max_winners < 0))
      return c.json({ ok: false, error: "max_winners は0以上の整数にしてください（0=無制限）" }, 400);
    const existing = await store.listPrizes(id);
    if (totalProbability(existing) + b.probability > 100)
      return c.json(
        { ok: false, error: `当選確率の合計が100%を超えます（現在 ${totalProbability(existing)}%）` },
        400,
      );
    const prize = await store.addPrize({
      lottery_id: id,
      name: b.name.trim(),
      description: b.description?.trim() || null,
      probability: b.probability,
      max_winners: b.max_winners && b.max_winners > 0 ? Math.floor(b.max_winners) : 0,
    });
    return c.json({ ok: true, prize });
  });
  app.put("/api/prizes/:id", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req.json<{
      name?: string;
      description?: string;
      probability?: number;
      max_winners?: number;
      active?: boolean;
    }>();
    const prize = await store.getPrize(id);
    if (!prize) return c.json({ ok: false, error: "not found" }, 404);
    const patch: Partial<Pick<LotteryPrize, "name" | "description" | "probability" | "max_winners" | "active">> = {};
    if (b.name !== undefined) {
      if (!b.name.trim()) return c.json({ ok: false, error: "name は空にできません" }, 400);
      patch.name = b.name.trim();
    }
    if (b.description !== undefined) patch.description = b.description?.trim() || null;
    if (b.probability !== undefined) {
      if (typeof b.probability !== "number" || !Number.isFinite(b.probability) || b.probability <= 0 || b.probability > 100)
        return c.json({ ok: false, error: "probability は 0 より大きく 100 以下にしてください" }, 400);
      patch.probability = b.probability;
    }
    if (b.max_winners !== undefined) {
      if (!Number.isInteger(b.max_winners) || b.max_winners < 0)
        return c.json({ ok: false, error: "max_winners は0以上の整数にしてください（0=無制限）" }, 400);
      patch.max_winners = b.max_winners;
    }
    if (b.active !== undefined) patch.active = !!b.active;
    // 更新後の「有効な景品」の確率合計が100%を超えないか検証（作成時の制約を編集でもすり抜けさせない）
    const willActive = patch.active !== undefined ? patch.active : prize.active;
    const willProb = patch.probability !== undefined ? patch.probability : prize.probability;
    if (willActive) {
      const siblings = (await store.listPrizes(prize.lottery_id)).filter((p) => p.id !== id && p.active);
      const total = siblings.reduce((a, p) => a + p.probability, 0) + willProb;
      if (total > 100)
        return c.json(
          { ok: false, error: `当選確率の合計が100%を超えます（この景品を除いて ${siblings.reduce((a, p) => a + p.probability, 0)}%）` },
          400,
        );
    }
    await store.updatePrize(id, patch);
    return c.json({ ok: true });
  });
  app.get("/api/draws", async (c) => {
    const draws = await store.listDraws(null, 200);
    const subs = await store.listSubscribers();
    const subMap = new Map(subs.map((s) => [s.id, s.email]));
    const lotteries = await store.listLotteries();
    const lotteryMap = new Map(lotteries.map((l) => [l.id, l.name]));
    const prizeMap = new Map<number, string>();
    for (const l of lotteries) for (const p of await store.listPrizes(l.id)) prizeMap.set(p.id, p.name);
    return c.json(
      draws.map((d) => ({
        ...d,
        email: subMap.get(d.subscriber_id) ?? "?",
        lottery_name: lotteryMap.get(d.lottery_id) ?? "?",
        prize_name: d.prize_id !== null ? (prizeMap.get(d.prize_id) ?? "?") : null,
      })),
    );
  });
  app.post("/api/draws/:id/fulfilled", async (c) => {
    const b = await c.req.json<{ fulfilled?: boolean }>().catch(() => ({}) as { fulfilled?: boolean });
    await store.setDrawFulfilled(Number(c.req.param("id")), b.fulfilled ?? true);
    return c.json({ ok: true });
  });

  // ---------- 管理: 景品交換 ----------
  app.get("/api/exchange/items", async (c) => {
    const items = await store.listExchangeItems();
    const out = [];
    for (const it of items)
      out.push({ ...it, redeemed: await store.countRedemptions(it.id) });
    return c.json(out);
  });
  app.post("/api/exchange/items", async (c) => {
    const b = await c.req.json<{
      name?: string;
      description?: string;
      cost_points?: number;
      stock?: number;
    }>();
    if (!b.name?.trim() || !b.cost_points || !Number.isFinite(b.cost_points) || b.cost_points < 1)
      return c.json({ ok: false, error: "name / cost_points(>=1) required" }, 400);
    const item = await store.createExchangeItem({
      name: b.name.trim(),
      description: b.description?.trim() || null,
      cost_points: Math.max(1, Math.floor(b.cost_points)),
      stock: typeof b.stock === "number" && b.stock >= 0 ? Math.floor(b.stock) : -1,
    });
    return c.json({ ok: true, item });
  });
  app.put("/api/exchange/items/:id", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req.json<{
      name?: string;
      description?: string;
      cost_points?: number;
      stock?: number;
      active?: boolean;
    }>();
    const patch: Partial<Pick<ExchangeItem, "name" | "description" | "cost_points" | "stock" | "active">> = {};
    if (b.name !== undefined) {
      if (!b.name.trim()) return c.json({ ok: false, error: "name は空にできません" }, 400);
      patch.name = b.name.trim();
    }
    if (b.description !== undefined) patch.description = b.description?.trim() || null;
    if (b.cost_points !== undefined) {
      if (!Number.isFinite(b.cost_points) || b.cost_points < 1)
        return c.json({ ok: false, error: "cost_points は1以上の数値にしてください" }, 400);
      patch.cost_points = Math.floor(b.cost_points);
    }
    if (b.stock !== undefined) {
      if (typeof b.stock !== "number" || !Number.isFinite(b.stock))
        return c.json({ ok: false, error: "stock が不正です" }, 400);
      // -1（未満は全て無制限）/ 0以上は在庫数
      patch.stock = b.stock < 0 ? -1 : Math.floor(b.stock);
    }
    if (b.active !== undefined) patch.active = !!b.active;
    await store.updateExchangeItem(id, patch);
    return c.json({ ok: true });
  });
  app.get("/api/exchange/redemptions", async (c) => {
    const [reds, subs, items] = await Promise.all([
      store.listRedemptions(200),
      store.listSubscribers(),
      store.listExchangeItems(),
    ]);
    const subMap = new Map(subs.map((s) => [s.id, s.email]));
    const itemMap = new Map(items.map((i) => [i.id, i.name]));
    return c.json(
      reds.map((r) => ({
        ...r,
        email: subMap.get(r.subscriber_id) ?? "?",
        item_name: itemMap.get(r.item_id) ?? "?",
      })),
    );
  });
  app.post("/api/exchange/redemptions/:id/status", async (c) => {
    const id = Number(c.req.param("id"));
    const b = await c.req.json<{ status?: "pending" | "done" | "canceled" }>();
    if (!b.status || !["pending", "done", "canceled"].includes(b.status))
      return c.json({ ok: false, error: "invalid status" }, 400);
    const red = await store.getRedemption(id);
    if (!red) return c.json({ ok: false, error: "not found" }, 404);
    // canceled は終端状態（返金済み）。ここから他状態へ戻すと「返金保持のまま景品も渡す」抜け穴になるため禁止
    if (red.status === "canceled")
      return c.json({ ok: false, error: "キャンセル済みの申請は変更できません" }, 400);
    if (b.status === red.status) return c.json({ ok: true }); // 変化なし
    // キャンセルへの遷移時のみポイント返却（dedupe_key で二重返金を防止）
    if (b.status === "canceled") {
      await store.addPointEvent(
        red.subscriber_id,
        red.points_spent,
        "exchange",
        `refund:redemption:${red.id}`,
        "交換キャンセル返却",
      );
    }
    await store.setRedemptionStatus(id, b.status);
    return c.json({ ok: true });
  });

  // ---------- 管理: 統計 / 監査 / 手動スケジューラ ----------
  app.get("/api/stats", async (c) => {
    const subs = await store.listSubscribers();
    const bcs = await store.listBroadcasts();
    const today = dayKey(now());
    return c.json({
      dryRun: cfg.dryRun,
      live: mailer.live && !cfg.dryRun,
      sms: {
        dryRun: cfg.smsDryRun,
        live: canSendSmsLive(cfg, smsSender),
        sentToday: await store.getSmsDailySendCount(today),
        maxPerDay: cfg.maxSmsPerDay,
        costPerSegment: cfg.smsCostPerSegment,
        subscribers: subs.filter((s) => s.sms_status === "active").length,
        // 自動付与されるフッター（送信者表示＋配信停止URL）の実際の文字数。
        // 画面の文字数カウンタが正しい通数・金額を出せるようにサーバ側で実測して渡す。
        footerChars: smsCharCount(buildSmsBody("", cfg, "x".repeat(32))),
      },
      subscribers: {
        total: subs.length,
        active: subs.filter((s) => s.status === "active").length,
        unsubscribed: subs.filter((s) => s.status === "unsubscribed").length,
        bounced: subs.filter((s) => s.status === "bounced").length,
      },
      broadcasts: {
        total: bcs.length,
        scheduled: bcs.filter((b) => b.status === "scheduled").length,
        sent: bcs.filter((b) => b.status === "sent").length,
      },
      lists: (await store.listLists()).length,
      campaigns: (await store.listCampaigns()).length,
      points: {
        totalBalance: (await store.listPointBalances()).reduce((a, b) => a + b.balance, 0),
        openLotteries: (await store.listLotteries()).filter((l) => l.status === "open").length,
        pendingRedemptions: (await store.listRedemptions(500)).filter((r) => r.status === "pending")
          .length,
      },
      recent: await store.listAudit(20),
    });
  });
  app.get("/api/audit", async (c) => c.json(await store.listAudit(100)));
  app.post("/api/scheduler/run", async (c) => {
    const summary = await runScheduler(store, mailer, cfg, now(), smsSender);
    return c.json({ ok: true, summary });
  });

  return app;
}

// ---------- 公開HTML（フォーム / サンクス） ----------
/**
 * 公開フォームの入力項目。
 * - email     : メールアドレスのみ
 * - email_sms : メールアドレス＋電話番号（SMS同意チェック付き）
 * - sms_only  : 電話番号のみ（メールアドレスは聞かない）
 */
type FormMode = "email" | "email_sms" | "sms_only";

function formPage(
  title: string,
  token: string,
  invalid: boolean,
  mode: FormMode = "email",
  isError = false,
): string {
  if (invalid) return htmlShell("登録フォーム", `<p>${escapeHtml(title)}</p>`);
  const err = isError
    ? `<p style="color:#b91c1c;font-size:13px;margin:0 0 12px">${escapeHtml(title)}</p>`
    : "";
  const heading = isError ? "ご登録" : title;
  const wantsSms = mode !== "email";
  // sms_only ではメール欄を出さない。フォームの action にモードを引き継ぐ
  const action = `/f/${escapeHtml(token)}${mode === "sms_only" ? "?sms=only" : mode === "email_sms" ? "?sms=1" : ""}`;
  const emailField =
    mode === "sms_only"
      ? ""
      : `<input type="email" name="email" placeholder="メールアドレス" required>`;
  const phoneField = wantsSms
    ? `<input type="tel" name="phone" placeholder="携帯番号（例: 090-1234-5678）" required>`
    : "";
  const consentField = wantsSms
    ? `<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;color:#444;margin:4px 0 10px;line-height:1.6">
           <input type="checkbox" name="sms_consent" value="on" style="width:auto;margin:3px 0 0" required>
           <span>ショートメッセージ（SMS）で連絡を受け取ることに同意します</span>
         </label>`
    : "";
  const note = wantsSms
    ? `<p style="font-size:11.5px;color:#888;line-height:1.6;margin-top:14px">
         ・SMSは同意いただいた方にのみお送りします。<br>
         ・SMSの文末にある停止リンクからいつでも配信を止められます。<br>
         ・通信料はお客様のご負担となります。</p>`
    : "";
  return htmlShell(
    "登録フォーム",
    `<h1>${escapeHtml(heading)}</h1>
       ${err}
       <form method="post" action="${action}">
         ${emailField}
         <input type="text" name="name" placeholder="お名前（任意）">
         ${phoneField}
         ${consentField}
         <button type="submit">登録する</button>
       </form>
       ${note}`,
  );
}
function thanksPage(msg: string, senderInfo?: string): string {
  const footer = senderInfo
    ? `<p style="font-size:12px;color:#888;line-height:1.7;margin-top:24px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(senderInfo)}</p>`
    : "";
  return htmlShell("完了", `<h1>${escapeHtml(msg)}</h1>${footer}`);
}
function htmlShell(title: string, inner: string): string {
  return `<!doctype html><html lang="ja"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>body{font-family:system-ui,-apple-system,"Hiragino Kaku Gothic ProN",sans-serif;background:#fff;color:#1a1a2e;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
.card{max-width:380px;width:90%;padding:40px;border:1px solid #eee;border-radius:16px;box-shadow:0 8px 30px rgba(0,0,0,.04)}
h1{font-size:18px}input,button{display:block;width:100%;box-sizing:border-box;padding:12px;margin:10px 0;border-radius:10px;border:1px solid #ddd;font-size:15px}
button{background:#1a1a2e;color:#fff;border:none;cursor:pointer}button:hover{opacity:.9}</style>
</head><body><div class="card">${inner}</div></body></html>`;
}
