// 中核ロジック（純粋関数寄り）: メール本文生成 / スケジューラ / 登録
import type { Mailer } from "./mailer.js";
import { openPixelTag, portalFooter, rewriteLinks } from "./points.js";
import { DryRunSmsSender, type SmsSender } from "./sms.js";
import type { Store } from "./store.js";
import type { RuntimeConfig, StepEmail, Subscriber } from "./types.js";
import { dayKey, escapeHtml, isSmsOnlyEmail } from "./util.js";

export interface TickSummary {
  broadcastsFinished: number;
  broadcastSends: number; // 実/dry送信した一斉配信通数（live）
  stepSends: number;
  smsSends: number; // 実送信したSMSメッセージ数（live）
  smsSegments: number; // 実送信したSMSのセグメント数（= 課金通数の見込み）
  skipped: number; // DRY_RUN/未承認/非アクティブでスキップ
  failed: number;
  processed: number; // 当ティックで処理した送信枠
  live: boolean;
  smsLive: boolean;
}

/** 配信本文に配信停止リンク + 送信者表示フッターを自動付与（特定電子メール法 / FR-DELIV-001） */
export function buildEmailHtml(
  bodyHtml: string,
  cfg: RuntimeConfig,
  unsubToken: string,
): string {
  const url = `${cfg.appBaseUrl.replace(/\/$/, "")}/u/${unsubToken}`;
  const footer = `
<hr style="margin-top:32px;border:none;border-top:1px solid #eee">
<div style="font-size:12px;color:#888;line-height:1.6;margin-top:12px">
  <div>${escapeHtml(cfg.senderInfo)}</div>
  <div>配信の停止をご希望の場合は <a href="${url}">こちら</a> からお手続きください。</div>
</div>`;
  return `${bodyHtml}\n${footer}`;
}

/**
 * ポイント計測込みの配信本文を組み立てる。
 * 1) 本文URL → クリック計測URL（/t/c/:linkId/:token）に書き換え
 * 2) 法定フッター（配信停止 + 送信者表示）を付与
 * 3) マイページ（ポイント/抽選会）案内 + 開封計測ピクセルを付与
 */
export async function buildTrackedEmailHtml(
  store: Store,
  bodyHtml: string,
  cfg: RuntimeConfig,
  sub: Subscriber,
  scope: "bc" | "step",
  refId: number,
): Promise<string> {
  const rewritten = await rewriteLinks(
    bodyHtml,
    cfg.appBaseUrl,
    sub.unsub_token,
    async (url) => (await store.getOrCreateTrackedLink(scope, refId, url)).id,
  );
  const base = buildEmailHtml(rewritten, cfg, sub.unsub_token);
  return `${base}\n${portalFooter(cfg.appBaseUrl, sub.unsub_token)}\n${openPixelTag(
    cfg.appBaseUrl,
    scope,
    refId,
    sub.unsub_token,
  )}`;
}

/** 実送信してよいか（FR-BC-003 / CONSTRAINTS §1） */
export function canSendLive(cfg: RuntimeConfig, approved: boolean): boolean {
  return cfg.dryRun === false && approved === true;
}

// ==================== SMS ====================

/**
 * SMS本文に送信者表示 + 配信停止URLを自動付与する（特定電子メール法対応）。
 * メール側の buildEmailHtml と対になる関数。
 *
 * 停止URLは必ず自ドメイン（/u/:token）を使う。bit.ly 等の汎用短縮URLは
 * キャリアの迷惑SMSフィルタでブロックされる主要因のため使用しない。
 */
export function buildSmsBody(
  bodyText: string,
  cfg: RuntimeConfig,
  unsubToken: string,
): string {
  // SMSは70文字ごとに課金されるので、フッターは短いほど直接コストが下がる。
  // 送信者名とベースURLはSMS専用の短い値を設定できる（未設定ならメールと同じ値）。
  const base = (cfg.smsBaseUrl || cfg.appBaseUrl).replace(/\/$/, "");
  const name = cfg.smsSenderInfo || cfg.senderInfo;
  const url = `${base}/u/${unsubToken}`;
  const sender = name ? `\n【${name}】` : "";
  return `${bodyText}${sender}\n配信停止 ${url}`;
}

/**
 * SMSの課金文字数。キャリア仕様に合わせ改行は2文字として数える
 * （UTAGE / MyASP など国内サービスの換算と同じ）。
 */
export function smsCharCount(text: string): number {
  // サロゲートペア（絵文字等）も1文字として数えるため [...text] で分解
  return [...text].reduce((n, ch) => n + (ch === "\n" ? 2 : 1), 0);
}

/**
 * SMSの課金通数（セグメント数）。
 * 70文字までは1通、71文字目以降は66文字ごとに1通加算（国内共通の課金単位）。
 */
export function smsSegments(text: string): number {
  const len = smsCharCount(text);
  if (len <= 0) return 0;
  if (len <= 70) return 1;
  return 1 + Math.ceil((len - 70) / 66);
}

/** SMSの上限文字数（連結SMSの上限。これを超える本文は送信前に弾く） */
export const SMS_MAX_CHARS = 660;
/** これを超えるとKDDI宛で遅延・制限の可能性がある閾値（5セグメント） */
export const SMS_WARN_CHARS = 330;

/** SMSを実送信してよいか（メールの承認フローとは独立。SMS_DRY_RUN が唯一のゲート） */
export function canSendSmsLive(cfg: RuntimeConfig, sender: SmsSender): boolean {
  return cfg.smsDryRun === false && sender.live === true;
}

/** その購読者にSMSを送ってよいか（番号あり + 明示同意あり） */
export function canReceiveSms(sub: Subscriber): boolean {
  return !!sub.phone && sub.sms_status === "active";
}

/** 電話番号だけで登録された読者か（メールアドレスを持っていない） */
export function isSmsOnlySubscriber(sub: Pick<Subscriber, "email">): boolean {
  return isSmsOnlyEmail(sub.email);
}

/** その購読者にメールを送ってよいか（SMS専用読者には送らない） */
export function canReceiveEmail(sub: Subscriber): boolean {
  return sub.status === "active" && !isSmsOnlySubscriber(sub);
}

const JST_OFFSET_MS = 9 * 3_600_000;
const DAY_MS = 86_400_000;

/**
 * ステップの送信予定時刻を計算する（基準 base = 登録時刻 or 前ステップ送信時刻）。
 * - immediate: base そのまま
 * - relative:  base + delay_minutes
 * - day_time:  base（JST）の日付 + delay_days 日後の send_time（JST）。
 *   計算結果が base 以前（例: 0日後の過去時刻）なら翌日に繰り越す。
 *   ※ エキスパの「条件充足済みなら即時配信」/ UTAGE の「配信されない」という
 *     両者の落とし穴を避け、「勝手に即時送信せず、消えもしない」安全側に倒す。
 */
export function computeStepSendAt(
  step: Pick<StepEmail, "timing_type" | "delay_minutes" | "delay_days" | "send_time">,
  base: number,
): number {
  const type = step.timing_type ?? "relative";
  if (type === "immediate") return base;
  if (type === "day_time" && step.send_time) {
    const m = /^(\d{1,2}):(\d{2})$/.exec(step.send_time);
    if (m) {
      const jst = new Date(base + JST_OFFSET_MS);
      let at =
        Date.UTC(
          jst.getUTCFullYear(),
          jst.getUTCMonth(),
          jst.getUTCDate() + (step.delay_days ?? 0),
          Number(m[1]),
          Number(m[2]),
        ) - JST_OFFSET_MS;
      if (at <= base) at += DAY_MS; // 過ぎていたら翌日（即時大量配信の事故防止）
      return at;
    }
  }
  return base + (step.delay_minutes ?? 0) * 60_000;
}

/** 購読者をステップキャンペーンへ登録（冪等 / 初回ステップのタイミングを反映） */
export async function enrollSubscriber(
  store: Store,
  campaignId: number,
  subscriberId: number,
  now: number,
): Promise<{ created: boolean }> {
  const steps = await store.listStepEmails(campaignId);
  const nextSendAt = steps.length ? computeStepSendAt(steps[0], now) : now;
  const { created } = await store.enroll(campaignId, subscriberId, nextSendAt, now);
  if (created)
    await store.audit("enroll", `campaign=${campaignId} sub=${subscriberId}`);
  return { created };
}

/**
 * スケジューラ本体（Cron 毎分）。
 * 1) 予約配信を楽観ロックで sending 化 → 受信者の未送信を上限内で送信
 * 2) 期限到来のステップ enrollment を1ステップ送信して前進
 * ティック上限 / 日次上限を超えない。失敗時はその配信を中断し次ティックで再開。
 */
export async function runScheduler(
  store: Store,
  mailer: Mailer,
  cfg: RuntimeConfig,
  now: number,
  smsSender: SmsSender = new DryRunSmsSender(),
): Promise<TickSummary> {
  const summary: TickSummary = {
    broadcastsFinished: 0,
    broadcastSends: 0,
    stepSends: 0,
    smsSends: 0,
    smsSegments: 0,
    skipped: 0,
    failed: 0,
    processed: 0,
    live: mailer.live && !cfg.dryRun,
    smsLive: canSendSmsLive(cfg, smsSender),
  };

  const today = dayKey(now);
  const dailySent = await store.getDailySendCount(today);
  let budget = Math.min(
    cfg.maxSendsPerTick,
    Math.max(0, cfg.maxSendsPerDay - dailySent),
  );
  let liveThisTick = 0;

  // SMSの送信枠はメールとは完全に別枠で管理する
  // （SMSは1通≒10円でメールの数百倍のコスト。メール枠を食う/食われるのを防ぐ）
  // 単位は「セグメント（課金通数）」。MAX_SMS_PER_DAY がそのまま1日のコスト上限になる。
  const smsDailySent = await store.getSmsDailySendCount(today);
  let smsBudget = Math.min(
    cfg.maxSmsPerTick,
    Math.max(0, cfg.maxSmsPerDay - smsDailySent),
  );
  let liveSmsThisTick = 0;

  // --- 1. 一斉配信 ---
  await store.claimDueBroadcasts(now); // scheduled & due → sending（楽観ロック）
  const sending = (await store.listBroadcasts()).filter(
    (b) => b.status === "sending",
  );
  for (const b of sending) {
    // v5: 一斉配信もメール/SMSの2チャネル。使う送信枠がチャネルごとに違う
    const bcIsSms = (b.channel ?? "email") === "sms";
    const remaining = () => (bcIsSms ? smsBudget : budget);
    // 受信者（active のみ）の未送信行を作成（冪等）
    const recipients = await store.activeSubscribersInList(b.list_id);
    await store.ensureBroadcastSends(
      b.id,
      recipients.map((r) => r.id),
    );
    let aborted = false;
    while (remaining() > 0) {
      const pend = await store.pendingBroadcastSends(b.id, remaining());
      if (pend.length === 0) break;
      for (const ps of pend) {
        if (remaining() <= 0) break;
        const sub = await store.getSubscriber(ps.subscriber_id);
        if (!sub || sub.status !== "active") {
          await store.markBroadcastSend(ps.id, "skipped", null, "inactive", now);
          summary.skipped++;
          if (bcIsSms) smsBudget--;
          else budget--;
          summary.processed++;
          continue;
        }
        if (bcIsSms) {
          // --- SMS 一斉配信 ---
          if (!canReceiveSms(sub)) {
            // 番号なし / 未同意 / SMS停止 → 送らずにスキップ記録
            await store.markBroadcastSend(ps.id, "skipped", null, "no_sms_consent", now);
            summary.skipped++;
            smsBudget--;
            summary.processed++;
            continue;
          }
          // 承認ゲートはメールと共通、送信ゲートはSMS専用（SMS_DRY_RUN）
          if (!b.approved || !canSendSmsLive(cfg, smsSender)) {
            await store.markBroadcastSend(
              ps.id,
              "skipped",
              null,
              !b.approved ? "not_approved" : "sms_dry_run",
              now,
            );
            summary.skipped++;
            smsBudget--;
            summary.processed++;
            continue;
          }
          const key = `sms-bc-${b.id}-${ps.subscriber_id}`;
          const body = buildSmsBody(b.body_text ?? "", cfg, sub.unsub_token);
          const segments = smsSegments(body);
          const r = await smsSender.send({ to: sub.phone as string, body }, key);
          if (r.ok) {
            await store.markBroadcastSend(ps.id, "sent", r.id ?? null, null, now);
            summary.smsSends++;
            summary.smsSegments += segments;
            liveSmsThisTick += segments;
            smsBudget -= segments;
            summary.processed++;
          } else {
            await store.markBroadcastSend(ps.id, "failed", null, r.error ?? "sms error", now);
            summary.failed++;
            smsBudget--;
            summary.processed++;
            aborted = true; // 外部障害 → この配信を中断し次ティックで再開
            break;
          }
          continue;
        }
        // --- メール 一斉配信 ---
        if (!canReceiveEmail(sub)) {
          // 電話番号だけで登録したSMS専用読者にはメールを送らない
          await store.markBroadcastSend(ps.id, "skipped", null, "sms_only", now);
          summary.skipped++;
          budget--;
          summary.processed++;
          continue;
        }
        if (!canSendLive(cfg, b.approved)) {
          await store.markBroadcastSend(
            ps.id,
            "skipped",
            null,
            cfg.dryRun ? "dry_run" : "not_approved",
            now,
          );
          summary.skipped++;
          budget--;
          summary.processed++;
          continue;
        }
        const key = `bc-${b.id}-${ps.subscriber_id}`;
        const html = await buildTrackedEmailHtml(store, b.body_html, cfg, sub, "bc", b.id);
        const r = await mailer.send(
          { to: sub.email, subject: b.subject, html },
          cfg.mailFrom,
          key,
        );
        if (r.ok) {
          await store.markBroadcastSend(ps.id, "sent", r.id ?? null, null, now);
          summary.broadcastSends++;
          liveThisTick++;
          budget--;
          summary.processed++;
        } else {
          await store.markBroadcastSend(
            ps.id,
            "failed",
            null,
            r.error ?? "send error",
            now,
          );
          summary.failed++;
          budget--;
          summary.processed++;
          aborted = true; // 外部障害 → この配信を中断し次ティックで再開
          break;
        }
      }
      if (aborted) break;
    }
    if (!aborted && (await store.countPendingBroadcastSends(b.id)) === 0) {
      await store.finishBroadcast(b.id, now);
      summary.broadcastsFinished++;
    }
  }

  // --- 2. ステップ配信（メール / SMS 混在シナリオ） ---
  if (budget > 0 || smsBudget > 0) {
    const due = await store.dueEnrollments(now, budget + smsBudget);
    for (const e of due) {
      if (budget <= 0 && smsBudget <= 0) break;
      const campaign = await store.getCampaign(e.campaign_id);
      const steps = await store.listStepEmails(e.campaign_id);
      if (!campaign || campaign.status !== "active") continue; // paused は触らない
      const sub = await store.getSubscriber(e.subscriber_id);
      if (!sub || sub.status !== "active") {
        // 配信停止/バウンス → 進行停止
        await store.advanceEnrollment(
          e.id,
          e.current_step,
          e.next_send_at,
          "canceled",
        );
        continue;
      }
      if (e.current_step >= steps.length) {
        await store.advanceEnrollment(
          e.id,
          e.current_step,
          e.next_send_at,
          "completed",
        );
        continue;
      }
      const step = steps[e.current_step];
      const isSms = (step.channel ?? "email") === "sms";
      // 該当チャネルの枠が尽きていたら、この enrollment は次ティックへ回す
      // （もう一方のチャネルの配信は止めない）
      if (isSms ? smsBudget <= 0 : budget <= 0) continue;
      let ok = true;
      if (!(await store.hasStepSend(e.id, step.id))) {
        if (isSms) {
          // --- SMS ステップ ---
          // 番号なし / 未同意 / SMS停止 / 送信オフ → 飛ばしてシナリオは前進させる
          if (!canReceiveSms(sub) || !canSendSmsLive(cfg, smsSender)) {
            await store.recordStepSend(e.id, step.id, null);
            summary.skipped++;
            smsBudget--;
          } else {
            const key = `sms-step-${e.id}-${step.id}`;
            const body = buildSmsBody(step.body_text ?? "", cfg, sub.unsub_token);
            // 課金はセグメント（70文字=1通、以降66文字ごと）単位なので、枠も
            // 通数ではなくセグメント数で消費する。こうしないと長文SMSのときに
            // 上限が示す金額を数倍超えてしまい「上限＝コスト上限」が崩れる。
            const segments = smsSegments(body);
            const r = await smsSender.send({ to: sub.phone as string, body }, key);
            if (r.ok) {
              await store.recordStepSend(e.id, step.id, r.id ?? null);
              summary.smsSends++;
              summary.smsSegments += segments;
              liveSmsThisTick += segments;
              smsBudget -= segments;
            } else {
              ok = false;
              summary.failed++;
              smsBudget--; // 失敗も1枠消費して、同一ティック内での再試行ループを防ぐ
            }
          }
        } else {
          // --- メール ステップ ---
          // SMS専用読者（メールアドレスなし）はこのステップを飛ばして前進させる
          if (!canReceiveEmail(sub) || !canSendLive(cfg, true)) {
            await store.recordStepSend(e.id, step.id, null);
            summary.skipped++;
          } else {
            const key = `step-${e.id}-${step.id}`;
            const html = await buildTrackedEmailHtml(store, step.body_html, cfg, sub, "step", step.id);
            const r = await mailer.send(
              { to: sub.email, subject: step.subject, html },
              cfg.mailFrom,
              key,
            );
            if (r.ok) {
              await store.recordStepSend(e.id, step.id, r.id ?? null);
              summary.stepSends++;
              liveThisTick++;
            } else {
              ok = false;
              summary.failed++;
            }
          }
          budget--;
        }
        summary.processed++;
      }
      if (ok) {
        const nextStep = e.current_step + 1;
        if (nextStep >= steps.length) {
          await store.advanceEnrollment(e.id, nextStep, e.next_send_at, "completed");
        } else {
          const nextAt = computeStepSendAt(steps[nextStep], now);
          await store.advanceEnrollment(e.id, nextStep, nextAt, "active");
        }
      }
      // ok=false: そのまま（next_send_at<=now のため次ティック再試行）
    }
  }

  if (liveThisTick > 0) await store.incrDailySendCount(today, liveThisTick);
  if (liveSmsThisTick > 0) await store.incrSmsDailySendCount(today, liveSmsThisTick);
  return summary;
}
