// SmsSender 抽象 + 実装（DryRun / Karaden / Twilio / Failing[テスト用]）
// mailer.ts と相似形。プロバイダはアダプタ差し替えで乗り換え可能にし、特定業者にロックインしない。
import type { OutSms, SendResult } from "./types.js";

export interface SmsSender {
  /**
   * SMS 1通送信。idempotencyKey は呼び出し側の冪等キー（step_sends の一意制約が主防御）。
   * 戻り値 ok=false は配送失敗（呼び出し側で次ティック再試行）。
   */
  send(msg: OutSms, idempotencyKey: string): Promise<SendResult>;
  /** 実送信を行うか（SMS_DRY_RUN の表示用） */
  readonly live: boolean;
}

/** SMS_DRY_RUN: 実際には送らず成功扱い（id=drysms:...） */
export class DryRunSmsSender implements SmsSender {
  readonly live = false;
  sent: { msg: OutSms; key: string }[] = [];
  async send(msg: OutSms, key: string): Promise<SendResult> {
    this.sent.push({ msg, key });
    return { ok: true, id: `drysms:${key}` };
  }
}

/**
 * 推奨プロバイダ: NTT C-Path / NTT CPaaS（NTTドコモビジネスX・国内直収・月額0円・従量）
 * 管理ポータル: https://ibportal.nttcpaas.com/
 *
 * API仕様（公式ドキュメント docs.nttcpaas.com に基づく / Infobip互換）:
 *   POST https://{baseUrl}.ibapi.nttcpaas.com/sms/3/messages
 *   Authorization: App {API_KEY}        ← Bearer でも Basic でもない独自形式
 *   Content-Type: application/json
 *   { "messages": [ { "from": "...", "destinations": [ { "to": "819012345678" } ], "text": "..." } ] }
 *
 * 注意点:
 * - **宛先は「+」なしの国際形式**（E.164から先頭の + を除いた `8190xxxxxxxx`）。
 * - `from` はトライアル中は `ServiceSMS`、本番契約後は払い出される送信元番号。
 * - `flash` パラメータは NTT CPaaS では非対応（送るとエラーになる）。
 * - ベースURLは**テナント固有のサブドメイン**（ポータルに表示される）。
 * - APIキーは**生成後2日間しか表示されない**ので、発行したらすぐ Secrets に登録すること。
 * - 冪等キーの仕組みはAPI側に無い。二重送信防止は step_sends の一意制約で担保する。
 * - HTTP 2xx は「送信受付（PENDING）」を意味する。最終的な到達可否は配信レポート（DLR）
 *   で確認する設計（notifyUrl の Webhook もしくは GET /sms/1/reports のポーリング）。
 *   ※ レポートは1度取得するとキューから消えるため、取得したら必ず保存すること。
 */
interface CPaaSResponse {
  bulkId?: string;
  messages?: {
    messageId?: string;
    status?: { groupName?: string };
    details?: { messageCount?: number };
  }[];
}

export class NttCPaaSSmsSender implements SmsSender {
  readonly live = true;
  private base: string;
  constructor(
    private apiKey: string,
    baseUrl: string,
    private from = "ServiceSMS",
    /** 配信レポート(DLR)の通知先。未指定なら送らない（P2で利用予定） */
    private notifyUrl?: string,
  ) {
    this.base = normalizeApiBase(baseUrl);
  }

  private post(path: string, payload: unknown): Promise<Response> {
    return fetch(`${this.base}${path}`, {
      method: "POST",
      headers: {
        Authorization: `App ${this.apiKey}`, // Bearer でも Basic でもない独自スキーム
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify(payload),
    });
  }

  private parse(res: Response, body: string, variant: string): SendResult {
    let data: CPaaSResponse | null = null;
    try {
      data = JSON.parse(body) as CPaaSResponse;
    } catch {
      /* 本文がJSONでなくても 2xx なら受付成功として扱う */
    }
    const first = data?.messages?.[0];
    const id = first?.messageId ?? data?.bulkId ?? "-";
    // 受付状態(PENDING等)と実際の課金通数を残すと、後から監査ログで突き合わせられる
    const group = first?.status?.groupName;
    const count = first?.details?.messageCount;
    const meta = [group, count !== undefined ? `${count}seg` : undefined, variant]
      .filter(Boolean)
      .join(":");
    return { ok: true, id: meta ? `${id}:${meta}` : id };
  }

  async send(msg: OutSms, key: string): Promise<SendResult> {
    const to = toCPaaSDestination(msg.to);
    try {
      // --- v3（公式OpenAPI仕様の形式: sender + content.text）---
      const message: Record<string, unknown> = {
        sender: this.from,
        // messageId には自前の冪等キーを入れて、DLR との突合に使えるようにする
        destinations: [{ to, messageId: key.slice(0, 200) }],
        content: { text: msg.body },
      };
      if (this.notifyUrl)
        message.webhooks = {
          delivery: { url: this.notifyUrl },
          contentType: "application/json",
          callbackData: key.slice(0, 4000),
        };
      const res = await this.post("/sms/3/messages", {
        messages: [message],
        options: {
          // 実際に課金された通数をレスポンスで受け取る
          includeSmsCountInResponse: true,
          // ⚠️ shortenUrl / trackClicks は API 側の既定が true。
          //    有効のままだと本文中のURLがプロバイダの短縮URLに書き換えられ、
          //    ①Mail Studio 自前のクリック計測（/t/c/:linkId/:token）が壊れる
          //    ②汎用短縮ドメインはキャリアの迷惑SMSフィルタでブロックされやすい
          //    の二重の実害が出るため、明示的に無効化する。
          tracking: { shortenUrl: false, trackClicks: false },
        },
      });
      const text = await res.text();
      if (res.ok) return this.parse(res, text, "v3");
      if (res.status !== 400)
        return { ok: false, error: `nttcpaas ${res.status}: ${text.slice(0, 200)}` };

      // --- 400 のときだけ v2 形式で1度だけ再試行 ---
      // 公式の日本語ガイドは v3 のパスに v2 のボディ（from / text）を載せた例を
      // 載せており、どちらが正かドキュメント間で矛盾している。400 は「1通も送信
      // されていない」ことを意味するので、ここで形式を変えて再試行しても二重送信に
      // はならない。どちらの形式で通ったかは戻り値の id 末尾に残す。
      const legacy: Record<string, unknown> = {
        from: this.from,
        destinations: [{ to }],
        text: msg.body,
      };
      if (this.notifyUrl) legacy.notifyUrl = this.notifyUrl;
      const res2 = await this.post("/sms/2/text/advanced", { messages: [legacy] });
      const text2 = await res2.text();
      if (res2.ok) return this.parse(res2, text2, "v2");
      return {
        ok: false,
        error: `nttcpaas v3 400: ${text.slice(0, 150)} / v2 ${res2.status}: ${text2.slice(0, 150)}`,
      };
    } catch (e) {
      return { ok: false, error: String(e) };
    }
  }
}

/**
 * ポータルに表示されるAPIベースURLを正規化する。
 * `yourtenant.ibapi.nttcpaas.com` のようにスキームなしで表示されるため、https:// を補う。
 * 末尾のスラッシュも取り除く。
 */
export function normalizeApiBase(baseUrl: string): string {
  const s = (baseUrl ?? "").trim().replace(/\/+$/, "");
  if (!s) return "";
  return /^https?:\/\//i.test(s) ? s : `https://${s}`;
}

/**
 * E.164（+819012345678）を NTT CPaaS が要求する「+なし国際形式」（819012345678）に変換する。
 */
export function toCPaaSDestination(e164: string): string {
  return (e164 ?? "").replace(/^\+/, "");
}

/**
 * 本番候補②/検証用: Twilio Programmable Messaging
 * 仕様が安定しており fetch のみで完結するため、疎通確認・フォールバックに使える。
 * ただし日本宛は国際網経由でキャリアの迷惑SMSフィルタに掛かりやすく、単価も国内直収より高い。
 * 本番の販促SMSには国内直収（Karaden / KDDI Message Cast 等）を使うこと。
 */
export class TwilioSmsSender implements SmsSender {
  readonly live = true;
  constructor(
    private accountSid: string,
    private authToken: string,
    private from: string,
  ) {}
  async send(msg: OutSms, _key: string): Promise<SendResult> {
    try {
      const body = new URLSearchParams({ To: msg.to, From: this.from, Body: msg.body });
      const res = await fetch(
        `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}/Messages.json`,
        {
          method: "POST",
          headers: {
            Authorization: `Basic ${btoa(`${this.accountSid}:${this.authToken}`)}`,
            "Content-Type": "application/x-www-form-urlencoded",
          },
          body: body.toString(),
        },
      );
      if (!res.ok) {
        const text = await res.text();
        return { ok: false, error: `twilio ${res.status}: ${text.slice(0, 200)}` };
      }
      const data = (await res.json()) as { sid?: string };
      return { ok: true, id: data.sid };
    } catch (e) {
      return { ok: false, error: String(e) };
    }
  }
}

/** テスト用: 指定回数だけ成功し、その後失敗（外部障害シミュレーション） */
export class ScriptedSmsSender implements SmsSender {
  readonly live = true;
  sent: { msg: OutSms; key: string }[] = [];
  private calls = 0;
  constructor(private failOnCall: number[] = []) {}
  async send(msg: OutSms, key: string): Promise<SendResult> {
    this.calls++;
    if (this.failOnCall.includes(this.calls))
      return { ok: false, error: "simulated sms failure" };
    this.sent.push({ msg, key });
    return { ok: true, id: `livesms:${key}` };
  }
}

// ---------------- 電話番号の正規化 ----------------

/**
 * 電話番号を E.164 に正規化する（既定は日本 +81）。
 * 例: "090-1234-5678" / "０９０１２３４５６７８" / "+81 90 1234 5678" → "+819012345678"
 * 正規化できない入力は null（呼び出し側で 400 を返す）。
 */
export function normalizePhoneE164(input: string, defaultCountry = "81"): string | null {
  if (!input) return null;
  // 全角英数字・全角プラスを半角へ
  const half = input.replace(/[０-９＋]/g, (ch) =>
    String.fromCharCode(ch.charCodeAt(0) - 0xfee0),
  );
  const s = half.replace(/[\s\-().]/g, "");
  if (s.startsWith("+")) {
    const digits = s.slice(1).replace(/\D/g, "");
    if (digits.length < 8 || digits.length > 15) return null;
    return `+${digits}`;
  }
  const digits = s.replace(/\D/g, "");
  if (!digits) return null;
  if (digits.startsWith("0")) {
    // 国内表記 0X... → +81X...
    const rest = digits.slice(1);
    if (rest.length < 8 || rest.length > 13) return null;
    return `+${defaultCountry}${rest}`;
  }
  // 既に国番号始まり（0 なし）とみなせる場合のみ許可
  if (digits.startsWith(defaultCountry) && digits.length >= 10 && digits.length <= 15)
    return `+${digits}`;
  return null;
}
