// 市民オンライン手続きポータル — APIサーバ
//
// マイナンバーカード認証（JPKIシミュレーション）で本人確認し、
// 来庁せずに証明書のオンライン請求等を行えるリファレンス実装。
// 申請はバックオフィスの各標準準拠システム(/link/application/inbound)へ
// 連携される想定（本実装では連携イベントを記録してシミュレート）。
import { createServer } from "node:http";
import { existsSync, readFileSync } from "node:fs";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { extname, join, normalize, resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import crypto from "node:crypto";
import { cardLogin, verifyToken, logout, bearer, MOCK_CITIZENS } from "./auth.mjs";
import { estimatorCatalog, runEstimate } from "./estimators.mjs";

// Workers等のバンドル環境では import.meta.url が無いため固定パスにフォールバック
//（メモリ保存モードではパスはMapのキーとしてのみ使われる）。
const __dirname = import.meta.url ? dirname(fileURLToPath(import.meta.url)) : "/app";
const port = Number(process.env.PORT || 8088);
const webRoot = resolve(__dirname, "../web");
// データ保存先。テスト等で分離したい場合は PORTAL_DATA_DIR で上書き可能。
const dataDir = process.env.PORTAL_DATA_DIR ? resolve(process.env.PORTAL_DATA_DIR) : resolve(__dirname, "data");
// PORTAL_STORAGE=memory: ファイルを書かずプロセスメモリに保持する。
// ディスクが使えない・揮発してよい実行環境（デモ環境等）向け。再起動でデータは消える。
const MEM = process.env.PORTAL_STORAGE === "memory" ? new Map() : null;
const applicationsFile = join(dataDir, "applications.json");
const proceduresFile = join(dataDir, "procedures.json");
const notificationsFile = join(dataDir, "notifications.json");

// 証明書の種類カタログ（オンライン請求可能なもの・第1弾）
const CERTIFICATE_TYPES = [
  { id: "juminhyo", name: "住民票の写し", unitFee: 300, system: "juki-web-reference", delivery: ["CONVENIENCE", "POSTAL"], note: "世帯全員/個人・続柄等の記載有無を選択できます。" },
  { id: "juminhyo-kisai", name: "住民票記載事項証明書", unitFee: 300, system: "juki-web-reference", delivery: ["CONVENIENCE", "POSTAL"], note: "" },
  { id: "inkan", name: "印鑑登録証明書", unitFee: 300, system: "inkan-registration-web", delivery: ["CONVENIENCE", "POSTAL"], note: "印鑑登録済みの方のみ請求できます。" },
  { id: "kazei", name: "課税証明書／所得証明書", unitFee: 300, system: "tax-system-webapp", delivery: ["CONVENIENCE", "POSTAL"], note: "年度を指定して請求できます。" },
  { id: "koseki", name: "戸籍全部事項証明書", unitFee: 450, system: "koseki-web", delivery: ["POSTAL"], note: "本籍地が当市の方が対象です。" },
  { id: "koseki-fuhyo", name: "戸籍の附票の写し", unitFee: 300, system: "koseki-fuhyo-web", delivery: ["CONVENIENCE", "POSTAL"], note: "" },
];

const POSTAL_FEE = 140; // 郵送時の送料(模擬)

// 手続きカテゴリ（ライフイベント別）。ハブ画面でグループ表示する。
const CATEGORY_LABELS = {
  moving: "引越し・住まい",
  family: "戸籍・証明",
  child: "子育て・教育",
  health: "健康・医療・介護",
  welfare: "福祉・くらし",
  tax: "税・年金",
  other: "選挙・その他",
};

// 来庁不要のオンライン手続きカタログ（証明書以外の届出・申請）。
// 本来は窓口（役所）に行かないとできない手続きを、各標準準拠システムへ連携する形で集約。
// fields: 申請フォームの入力項目定義（type=text|date|tel|textarea|select、selectはoptions指定）
const PROCEDURE_TYPES = [
  // ── 引越し・住まい（住民記録）──
  {
    id: "moveout", name: "転出届（引越し・市外への転出）", system: "juki-web-reference", category: "moving",
    summary: "市外へ引越すときの届出です。提出後、転出証明書情報が新住所地の手続きに引き継がれます。",
    fields: [
      { key: "moveOutDate", label: "転出予定日", type: "date", required: true },
      { key: "newAddress", label: "新しい住所", type: "text", required: true, placeholder: "例: ◯◯県△△市□□町1-2-3" },
      { key: "movingMembers", label: "一緒に転出する家族（任意）", type: "textarea", required: false, placeholder: "氏名を改行で入力（本人のみの場合は空欄）" },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true, placeholder: "090-0000-0000" },
    ],
  },
  {
    id: "movein", name: "転入届（引越し・市外から）", system: "juki-web-reference", category: "moving",
    summary: "市外から当市へ引越してきたときの届出です。前住所地の転出証明書情報をもとに受け付けます。",
    fields: [
      { key: "moveInDate", label: "転入日（引越し日）", type: "date", required: true },
      { key: "prevAddress", label: "前の住所", type: "text", required: true, placeholder: "例: ◯◯県△△市□□町1-2-3" },
      { key: "newAddress", label: "当市での新しい住所", type: "text", required: true },
      { key: "movingMembers", label: "一緒に転入する家族（任意）", type: "textarea", required: false },
    ],
  },
  {
    id: "moveinside", name: "転居届（市内での引越し）", system: "juki-web-reference", category: "moving",
    summary: "当市内で住所を変えたときの届出です。",
    fields: [
      { key: "moveDate", label: "引越し日", type: "date", required: true },
      { key: "newAddress", label: "新しい住所（市内）", type: "text", required: true },
    ],
  },
  {
    id: "worldchange", name: "世帯変更届（世帯主変更・世帯合併等）", system: "juki-web-reference", category: "moving",
    summary: "世帯主の変更や世帯の分離・合併などの届出です。",
    fields: [
      { key: "changeType", label: "変更の種類", type: "select", required: true, options: ["世帯主変更", "世帯分離", "世帯合併"] },
      { key: "changeDate", label: "変更日", type: "date", required: true },
      { key: "detail", label: "内容（新世帯主氏名など）", type: "textarea", required: false },
    ],
  },
  // ── 戸籍・証明 ──
  {
    id: "birth", name: "出生届", system: "koseki-web", category: "family",
    summary: "お子さまが生まれたときの届出です。あわせて住民登録・児童手当等の手続きにつながります。",
    fields: [
      { key: "childName", label: "生まれたお子さまの氏名", type: "text", required: true },
      { key: "birthDate", label: "生年月日", type: "date", required: true },
      { key: "birthPlace", label: "出生場所", type: "text", required: true, placeholder: "例: ◯◯市民病院" },
      { key: "parentRelation", label: "届出人と子の関係", type: "select", required: true, options: ["父", "母", "その他"] },
    ],
  },
  {
    id: "death", name: "死亡届", system: "vital-statistics-system-webapp", category: "family",
    summary: "ご家族等が亡くなられたときの届出です。火葬許可の申請ともつながります。",
    fields: [
      { key: "deceasedName", label: "亡くなられた方の氏名", type: "text", required: true },
      { key: "deathDate", label: "死亡年月日", type: "date", required: true },
      { key: "reporterRelation", label: "届出人と故人の関係", type: "text", required: true, placeholder: "例: 長男" },
    ],
  },
  {
    id: "marriage", name: "婚姻届", system: "koseki-web", category: "family",
    summary: "結婚したときの届出です。",
    fields: [
      { key: "partnerName", label: "配偶者の氏名", type: "text", required: true },
      { key: "marriageDate", label: "婚姻日", type: "date", required: true },
      { key: "newSurname", label: "婚姻後の氏（任意）", type: "text", required: false },
    ],
  },
  {
    id: "divorce", name: "離婚届", system: "koseki-web", category: "family",
    summary: "離婚したときの届出です。",
    fields: [
      { key: "partnerName", label: "配偶者の氏名", type: "text", required: true },
      { key: "divorceDate", label: "離婚日", type: "date", required: true },
    ],
  },
  {
    id: "inkan-reg", name: "印鑑登録の申請", system: "inkan-registration-web", category: "family",
    summary: "印鑑登録を新たに行う申請です。登録後は印鑑登録証明書をオンライン請求できます。",
    fields: [
      { key: "sealNote", label: "登録する印鑑の確認事項", type: "textarea", required: true, placeholder: "印影の特徴・サイズなど（本人確認のうえ登録します）" },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
    ],
  },
  // ── 子育て・教育 ──
  {
    id: "childallowance", name: "児童手当の認定請求", system: "ChildAllowanceSystem", category: "child",
    summary: "出生・転入などにより新たに児童手当を受けるための請求です。",
    fields: [
      { key: "childName", label: "対象児童の氏名", type: "text", required: true },
      { key: "childBirthDate", label: "対象児童の生年月日", type: "date", required: true },
      { key: "bankAccount", label: "振込先口座（銀行・支店・口座番号）", type: "text", required: true, placeholder: "例: ◯◯銀行 △△支店 普通 1234567" },
    ],
  },
  {
    id: "childrearing", name: "児童扶養手当の認定請求", system: "child-rearing-allowance-web", category: "child",
    summary: "ひとり親家庭等が児童扶養手当を受けるための認定請求です。",
    fields: [
      { key: "childName", label: "対象児童の氏名", type: "text", required: true },
      { key: "householdStatus", label: "世帯の状況", type: "select", required: true, options: ["母子家庭", "父子家庭", "養育者", "その他"] },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "nursery", name: "保育所等の入所申込", system: "kosodate_shien_system", category: "child",
    summary: "保育所・認定こども園等への入所を申し込みます。",
    fields: [
      { key: "childName", label: "お子さまの氏名", type: "text", required: true },
      { key: "desiredStartDate", label: "入所希望日", type: "date", required: true },
      { key: "desiredFacility", label: "希望する施設（第1希望）", type: "text", required: true },
    ],
  },
  {
    id: "schoolaid", name: "就学援助の申請", system: "school-aid-web", category: "child",
    summary: "経済的な理由で就学が困難な世帯への学用品費・給食費等の援助申請です。",
    fields: [
      { key: "childName", label: "対象児童・生徒の氏名", type: "text", required: true },
      { key: "schoolName", label: "在学する学校名", type: "text", required: true },
      { key: "reason", label: "申請理由", type: "textarea", required: true },
    ],
  },
  {
    id: "enrollment", name: "就学・転校の届出", system: "school-enrollment-system", category: "child",
    summary: "入学・転校などの就学に関する届出です。",
    fields: [
      { key: "childName", label: "お子さまの氏名", type: "text", required: true },
      { key: "type", label: "届出の種類", type: "select", required: true, options: ["入学", "転入学", "転出学", "区域外就学"] },
      { key: "schoolName", label: "学校名", type: "text", required: true },
    ],
  },
  // ── 健康・医療・介護 ──
  {
    id: "kokuho-join", name: "国民健康保険の加入・喪失届", system: "KokuhoWebSystem", category: "health",
    summary: "就職・退職・転入転出などに伴う国民健康保険の加入・喪失の届出です。",
    fields: [
      { key: "eventType", label: "届出の種類", type: "select", required: true, options: ["加入", "喪失"] },
      { key: "eventDate", label: "事由発生日", type: "date", required: true },
      { key: "reason", label: "事由（退職・転入など）", type: "text", required: true },
    ],
  },
  {
    id: "kokuho-highcost", name: "高額療養費の支給申請", system: "KokuhoWebSystem", category: "health",
    summary: "ひと月の医療費が自己負担限度額を超えたときの払い戻し申請です。",
    fields: [
      { key: "treatmentMonth", label: "診療した月", type: "text", required: true, placeholder: "例: 2026-05" },
      { key: "medicalInstitution", label: "医療機関名", type: "text", required: true },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "nenkin-exempt", name: "国民年金保険料の免除・納付猶予申請", system: "KokuminNenkinSystem_Web", category: "health",
    summary: "所得が少ない場合などに国民年金保険料の免除・納付猶予を申請します。",
    fields: [
      { key: "fiscalYear", label: "申請する年度", type: "text", required: true, placeholder: "例: 2026年度" },
      { key: "reason", label: "申請理由", type: "select", required: true, options: ["失業", "所得減少", "学生", "その他"] },
    ],
  },
  {
    id: "kaigo-nintei", name: "要介護・要支援認定の申請", system: "LongTermCareInsuranceSystem_Web", category: "health",
    summary: "介護保険のサービス利用に必要な要介護・要支援認定の申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "applyType", label: "申請区分", type: "select", required: true, options: ["新規", "更新", "区分変更"] },
      { key: "conditionNote", label: "心身の状況（任意）", type: "textarea", required: false },
    ],
  },
  {
    id: "elderly-genngaku", name: "後期高齢者医療 限度額適用認定の申請", system: "ElderlyHealthcareSystem", category: "health",
    summary: "後期高齢者医療制度で、医療費の窓口負担を限度額までにする認定証の申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "reason", label: "申請理由（任意）", type: "textarea", required: false },
    ],
  },
  {
    id: "kenshin", name: "健康診査の受診申込", system: "HealthManagementSystem", category: "health",
    summary: "各種健康診査・がん検診などの受診を申し込みます。",
    fields: [
      { key: "course", label: "受診コース", type: "select", required: true, options: ["基本健診", "がん検診", "歯科健診", "骨粗しょう症検診"] },
      { key: "desiredDate", label: "希望日", type: "date", required: true },
    ],
  },
  {
    id: "tokutei", name: "特定健康診査の受診申込", system: "tokutei-kenshin-system", category: "health",
    summary: "メタボリックシンドローム対策の特定健診の受診を申し込みます。",
    fields: [
      { key: "desiredDate", label: "希望日", type: "date", required: true },
      { key: "institution", label: "希望する医療機関（任意）", type: "text", required: false },
    ],
  },
  // ── 福祉・くらし ──
  {
    id: "shogai-service", name: "障害福祉サービスの支給申請", system: "ShogaishaFukushiSystem", category: "welfare",
    summary: "障害福祉サービス（居宅介護・就労支援等）の利用に係る支給申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "serviceType", label: "希望するサービス", type: "text", required: true, placeholder: "例: 居宅介護、就労継続支援B型" },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
    ],
  },
  {
    id: "seikatsu-soudan", name: "生活保護の相談予約", system: "SeikatsuHogoSystem", category: "welfare",
    summary: "生活にお困りの方の相談予約です。内容は担当ケースワーカーが確認し、ご連絡します。",
    fields: [
      { key: "preferredDate", label: "相談希望日", type: "date", required: true },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
      { key: "summary", label: "相談内容の概要（任意）", type: "textarea", required: false },
    ],
  },
  {
    id: "cremation", name: "火葬許可の申請", system: "cremation-permit-system", category: "welfare",
    summary: "死亡届に伴う火葬（埋葬）許可の申請です。",
    fields: [
      { key: "deceasedName", label: "亡くなられた方の氏名", type: "text", required: true },
      { key: "deathDate", label: "死亡年月日", type: "date", required: true },
      { key: "cremationDate", label: "火葬希望日", type: "date", required: true },
      { key: "crematorium", label: "希望する火葬場（任意）", type: "text", required: false },
    ],
  },
  // ── 税・年金 ──
  {
    id: "tax-declaration", name: "市・県民税の申告", system: "tax-system-webapp", category: "tax",
    summary: "前年の所得について市・県民税（住民税）を申告します。",
    fields: [
      { key: "fiscalYear", label: "申告する年度", type: "text", required: true, placeholder: "例: 2026年度" },
      { key: "incomeNote", label: "所得の内容", type: "textarea", required: true, placeholder: "給与・年金・事業所得などの概要" },
    ],
  },
  // ── 選挙・その他 ──
  {
    id: "elector-kijitsu", name: "期日前投票の宣誓書（事前提出）", system: "electoral-roll-web", category: "other",
    summary: "期日前投票をスムーズに行うための宣誓書を事前にオンライン提出します。",
    fields: [
      { key: "electionName", label: "対象の選挙名", type: "text", required: true, placeholder: "例: ◯◯市長選挙" },
      { key: "votingDate", label: "期日前投票の予定日", type: "date", required: true },
      { key: "reason", label: "当日投票に行けない理由", type: "select", required: true, options: ["仕事", "旅行・外出", "冠婚葬祭", "病気・出産", "その他"] },
    ],
  },

  // ════════ 仕様書突合により追加した手続き（公式機能要件に存在し未掲載だったもの）════════
  // ── 子育て・教育（児童手当の額改定・現況届、子育て支援の各種給付認定）──
  {
    id: "child-amendment", name: "児童手当の額改定請求", system: "ChildAllowanceSystem", category: "child",
    summary: "出生などで対象児童が増えたとき、児童手当の額を改定するための請求です。",
    fields: [
      { key: "childName", label: "新たに対象となる児童の氏名", type: "text", required: true },
      { key: "childBirthDate", label: "生年月日", type: "date", required: true },
      { key: "reason", label: "改定の理由", type: "select", required: true, options: ["出生", "児童の増加", "その他"] },
    ],
  },
  {
    id: "child-genkyo", name: "児童手当の現況届", system: "ChildAllowanceSystem", category: "child",
    summary: "毎年6月の現況確認の届出です（対象となる方のみ）。",
    fields: [
      { key: "targetYear", label: "対象年度", type: "text", required: true, placeholder: "例: 2026年度" },
      { key: "statusNote", label: "現況（変更があれば記入）", type: "textarea", required: false },
    ],
  },
  {
    id: "kyoiku-hoiku-nintei", name: "教育・保育給付認定の申請（支給認定）", system: "kosodate_shien_system", category: "child",
    summary: "保育所・認定こども園等を利用するための保育の必要性の認定（1〜3号）申請です。",
    fields: [
      { key: "childName", label: "お子さまの氏名", type: "text", required: true },
      { key: "ninteiType", label: "希望する認定区分", type: "select", required: true, options: ["1号(教育)", "2号(保育・3歳以上)", "3号(保育・3歳未満)"] },
      { key: "needReason", label: "保育を必要とする理由", type: "select", required: true, options: ["就労", "妊娠・出産", "疾病・障害", "介護・看護", "求職活動", "その他"] },
    ],
  },
  {
    id: "shisetsu-riyo-nintei", name: "施設等利用給付認定の申請（幼児教育・保育の無償化）", system: "kosodate_shien_system", category: "child",
    summary: "幼稚園の預かり保育・認可外保育施設等の利用料無償化を受けるための認定申請です。",
    fields: [
      { key: "childName", label: "お子さまの氏名", type: "text", required: true },
      { key: "facilityType", label: "利用する施設・事業", type: "text", required: true, placeholder: "例: 認可外保育施設、預かり保育" },
      { key: "needReason", label: "保育を必要とする理由", type: "select", required: true, options: ["就労", "妊娠・出産", "疾病・障害", "介護・看護", "求職活動", "該当なし(新2号/新3号以外)"] },
    ],
  },
  {
    id: "school-change", name: "就学校変更・区域外就学の申請", system: "school-enrollment-system", category: "child",
    summary: "指定校以外への就学（就学校変更）や、住所地以外の学校への区域外就学を申請します。",
    fields: [
      { key: "childName", label: "お子さまの氏名", type: "text", required: true },
      { key: "applyType", label: "申請の種類", type: "select", required: true, options: ["就学校変更", "区域外就学", "国・私立就学の届出"] },
      { key: "schoolName", label: "希望（就学先）の学校名", type: "text", required: true },
      { key: "reason", label: "理由", type: "textarea", required: true },
    ],
  },
  // ── 健康・医療・介護（国保の各種給付、産前産後免除、介護の負担軽減）──
  {
    id: "kokuho-shussan", name: "出産育児一時金の支給申請（国保）", system: "KokuhoWebSystem", category: "health",
    summary: "国民健康保険の被保険者が出産したときの一時金の支給申請です。",
    fields: [
      { key: "birthDate", label: "出産年月日", type: "date", required: true },
      { key: "babyName", label: "お子さまの氏名（任意）", type: "text", required: false },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "kokuho-sousai", name: "葬祭費の支給申請（国保）", system: "KokuhoWebSystem", category: "health",
    summary: "国民健康保険の被保険者が亡くなったとき、葬祭を行った方への葬祭費の支給申請です。",
    fields: [
      { key: "deceasedName", label: "亡くなられた方の氏名", type: "text", required: true },
      { key: "deathDate", label: "死亡年月日", type: "date", required: true },
      { key: "applicantRelation", label: "申請者と故人の関係", type: "text", required: true },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "kokuho-gendogaku", name: "限度額適用認定の申請（国保）", system: "KokuhoWebSystem", category: "health",
    summary: "国民健康保険で、医療費の窓口負担を限度額までにとどめる認定証の申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "reason", label: "申請理由（入院予定など・任意）", type: "textarea", required: false },
    ],
  },
  {
    id: "nenkin-sanzen", name: "国民年金 産前産後期間の保険料免除", system: "KokuminNenkinSystem_Web", category: "health",
    summary: "出産前後の一定期間、国民年金保険料が免除される届出です。",
    fields: [
      { key: "dueDate", label: "出産予定日／出産日", type: "date", required: true },
      { key: "childName", label: "お子さまの氏名（任意）", type: "text", required: false },
    ],
  },
  {
    id: "kaigo-gendogaku", name: "介護保険 負担限度額認定の申請", system: "LongTermCareInsuranceSystem_Web", category: "health",
    summary: "施設サービスの食費・居住費の負担を軽減する負担限度額認定の申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "bankAccount", label: "振込先口座（該当時・任意）", type: "text", required: false },
    ],
  },
  {
    id: "kaigo-kougaku", name: "高額介護サービス費の支給申請", system: "LongTermCareInsuranceSystem_Web", category: "health",
    summary: "ひと月の介護サービス費の自己負担が上限を超えたときの払い戻し申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "serviceMonth", label: "対象の月", type: "text", required: true, placeholder: "例: 2026-05" },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  // ── 福祉・くらし（障害者手帳・自立支援医療）──
  {
    id: "shogai-techo", name: "障害者手帳の交付申請", system: "ShogaishaFukushiSystem", category: "welfare",
    summary: "身体障害者手帳・療育手帳・精神障害者保健福祉手帳の交付申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "techoType", label: "手帳の種類", type: "select", required: true, options: ["身体障害者手帳", "療育手帳", "精神障害者保健福祉手帳"] },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
    ],
  },
  {
    id: "jiritsu-iryo", name: "自立支援医療の支給認定申請", system: "ShogaishaFukushiSystem", category: "welfare",
    summary: "更生医療・育成医療・精神通院医療の自己負担を軽減する支給認定の申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "iryoType", label: "区分", type: "select", required: true, options: ["更生医療", "育成医療", "精神通院医療"] },
      { key: "medicalInstitution", label: "受診する医療機関", type: "text", required: true },
    ],
  },
  // ── 戸籍・証明（印鑑登録の廃止）──
  {
    id: "inkan-haishi", name: "印鑑登録の廃止申請", system: "inkan-registration-web", category: "family",
    summary: "登録している印鑑の登録を廃止する申請です。",
    fields: [
      { key: "reason", label: "廃止の理由", type: "select", required: true, options: ["改印（登録し直し）", "紛失", "その他"] },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
    ],
  },
  // ── 税・年金（税の減免）──
  {
    id: "tax-genmen", name: "軽自動車税（種別割）の減免申請", system: "tax-system-webapp", category: "tax",
    summary: "障害のある方等が使用する軽自動車などの税の減免申請です。",
    fields: [
      { key: "plateNumber", label: "対象車両のナンバー", type: "text", required: true, placeholder: "例: ◯◯市 あ 12-34" },
      { key: "reason", label: "減免の理由", type: "select", required: true, options: ["身体障害者等のため", "公益のため", "その他"] },
      { key: "detail", label: "補足（任意）", type: "textarea", required: false },
    ],
  },
  // ── 選挙・その他（不在者投票・在外選挙人登録）──
  {
    id: "fuzaisha-vote", name: "不在者投票の請求", system: "electoral-roll-web", category: "other",
    summary: "出張・入院・施設入所などで投票所に行けないときの不在者投票の請求です。",
    fields: [
      { key: "electionName", label: "対象の選挙名", type: "text", required: true },
      { key: "stayPlace", label: "滞在地・施設名など", type: "text", required: true },
      { key: "reason", label: "事由", type: "select", required: true, options: ["出張・旅行", "入院", "施設入所", "その他"] },
    ],
  },
  {
    id: "zaigai-elector", name: "在外選挙人名簿の登録申請", system: "electoral-roll-web", category: "other",
    summary: "海外に住む方が国政選挙で投票するための在外選挙人名簿への登録申請です。",
    fields: [
      { key: "country", label: "居住国・都市", type: "text", required: true },
      { key: "address", label: "在外の住所", type: "text", required: true },
      { key: "applyRoute", label: "申請方法", type: "select", required: true, options: ["出国時申請（市区町村）", "在外公館申請"] },
    ],
  },

  // ════════ 細かい手続き（戸籍の各種届・各制度の付随申請）════════
  // ── 戸籍・証明（戸籍の各種届）──
  {
    id: "koseki-tenseki", name: "転籍届（本籍を移す）", system: "koseki-web", category: "family",
    summary: "本籍地を別の場所へ移すときの届出です。",
    fields: [
      { key: "newHonseki", label: "新しい本籍", type: "text", required: true },
      { key: "tensekiDate", label: "転籍日", type: "date", required: true },
    ],
  },
  {
    id: "koseki-youshi", name: "養子縁組届", system: "koseki-web", category: "family",
    summary: "養子縁組をするときの届出です。",
    fields: [
      { key: "adoptiveParent", label: "養親の氏名", type: "text", required: true },
      { key: "adoptedChild", label: "養子の氏名", type: "text", required: true },
      { key: "kumiaiDate", label: "縁組日", type: "date", required: true },
    ],
  },
  {
    id: "koseki-rien", name: "養子離縁届", system: "koseki-web", category: "family",
    summary: "養子縁組を解消するときの届出です。",
    fields: [
      { key: "adoptiveParent", label: "養親の氏名", type: "text", required: true },
      { key: "adoptedChild", label: "養子の氏名", type: "text", required: true },
      { key: "rienDate", label: "離縁日", type: "date", required: true },
    ],
  },
  {
    id: "koseki-ninchi", name: "認知届", system: "koseki-web", category: "family",
    summary: "父が子を認知するときの届出です。",
    fields: [
      { key: "childName", label: "認知する子の氏名", type: "text", required: true },
      { key: "fatherName", label: "認知する父の氏名", type: "text", required: true },
      { key: "ninchiDate", label: "認知日", type: "date", required: true },
    ],
  },
  {
    id: "koseki-nyuseki", name: "入籍届", system: "koseki-web", category: "family",
    summary: "子を父または母の戸籍に入れるときの届出です（家庭裁判所の許可後）。",
    fields: [
      { key: "childName", label: "入籍する子の氏名", type: "text", required: true },
      { key: "targetKoseki", label: "入る戸籍（筆頭者）", type: "text", required: true },
      { key: "permitDate", label: "家裁の許可日", type: "date", required: false },
    ],
  },
  {
    id: "koseki-bunseki", name: "分籍届", system: "koseki-web", category: "family",
    summary: "成人が新しい戸籍をつくる（分籍する）ときの届出です。",
    fields: [
      { key: "newHonseki", label: "新しい本籍", type: "text", required: true },
      { key: "bunsekiDate", label: "分籍日", type: "date", required: true },
    ],
  },
  {
    id: "koseki-fujuri", name: "戸籍届出の不受理申出", system: "koseki-web", category: "family",
    summary: "本人の知らない間に届出（婚姻・離婚・養子縁組等）が受理されないようにする申出です。",
    fields: [
      { key: "targetKind", label: "対象とする届出", type: "select", required: true, options: ["婚姻届", "協議離婚届", "養子縁組届", "養子離縁届", "認知届", "すべて"] },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
    ],
  },
  {
    id: "inkan-saikofu", name: "印鑑登録証の再交付", system: "inkan-registration-web", category: "family",
    summary: "印鑑登録証（カード）を紛失・破損したときの再交付申請です。",
    fields: [
      { key: "reason", label: "再交付の理由", type: "select", required: true, options: ["紛失", "破損", "その他"] },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
    ],
  },
  // ── 引越し・住まい（住民記録の付随請求）──
  {
    id: "juki-fujuri", name: "住民異動届の不受理申出", system: "juki-web-reference", category: "moving",
    summary: "本人の知らない間に転居・転出等の住民異動届が受理されないようにする申出です。",
    fields: [
      { key: "reason", label: "申出の理由（任意）", type: "textarea", required: false },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
    ],
  },
  {
    id: "juminhyo-code", name: "住民票コードの変更請求", system: "juki-web-reference", category: "moving",
    summary: "住民票コードの変更を請求します。",
    fields: [
      { key: "reason", label: "変更を希望する理由", type: "textarea", required: true },
    ],
  },
  {
    id: "mynumber-change", name: "個人番号（マイナンバー）の変更請求", system: "juki-web-reference", category: "moving",
    summary: "個人番号が漏えいして不正に用いられるおそれがある場合などの変更請求です。",
    fields: [
      { key: "reason", label: "変更を希望する理由", type: "textarea", required: true },
    ],
  },
  // ── 子育て・教育 ──
  {
    id: "childrearing-genkyo", name: "児童扶養手当の現況届", system: "child-rearing-allowance-web", category: "child",
    summary: "毎年8月の現況確認の届出です（受給資格の継続確認）。",
    fields: [
      { key: "targetYear", label: "対象年度", type: "text", required: true, placeholder: "例: 2026年度" },
      { key: "statusNote", label: "現況（変更があれば記入）", type: "textarea", required: false },
    ],
  },
  {
    id: "hoiku-genmen", name: "保育料（利用者負担額）の減免申請", system: "kosodate_shien_system", category: "child",
    summary: "災害・所得の急減などで保育料の減額・免除を受けるための申請です。",
    fields: [
      { key: "childName", label: "お子さまの氏名", type: "text", required: true },
      { key: "reason", label: "減免を必要とする理由", type: "textarea", required: true },
    ],
  },
  // ── 健康・医療・介護 ──
  {
    id: "kokuho-ryoyohi", name: "療養費の支給申請（国保）", system: "KokuhoWebSystem", category: "health",
    summary: "やむを得ず全額自己負担したとき等に、国保から療養費の払い戻しを受ける申請です。",
    fields: [
      { key: "treatmentDate", label: "診療を受けた日", type: "date", required: true },
      { key: "detail", label: "内容（治療用装具・全額自己負担の理由など）", type: "textarea", required: true },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "kokuho-genmen", name: "国民健康保険料（税）の減免申請", system: "KokuhoWebSystem", category: "health",
    summary: "災害・所得の急減などで国保料（税）の減額・免除を受けるための申請です。",
    fields: [
      { key: "reason", label: "減免を必要とする理由", type: "select", required: true, options: ["災害", "所得の急減", "失業", "その他"] },
      { key: "detail", label: "補足（任意）", type: "textarea", required: false },
    ],
  },
  {
    id: "nenkin-gakusei", name: "国民年金 学生納付特例の申請", system: "KokuminNenkinSystem_Web", category: "health",
    summary: "学生が在学中の国民年金保険料の納付猶予を受ける申請です。",
    fields: [
      { key: "schoolName", label: "在学する学校名", type: "text", required: true },
      { key: "fiscalYear", label: "申請する年度", type: "text", required: true, placeholder: "例: 2026年度" },
    ],
  },
  {
    id: "nenkin-saikofu", name: "基礎年金番号通知書の再交付申請", system: "KokuminNenkinSystem_Web", category: "health",
    summary: "基礎年金番号通知書を紛失したとき等の再交付申請です。",
    fields: [
      { key: "reason", label: "再交付の理由", type: "select", required: true, options: ["紛失", "破損", "その他"] },
    ],
  },
  {
    id: "elderly-ryoyohi", name: "療養費の支給申請（後期高齢者医療）", system: "ElderlyHealthcareSystem", category: "health",
    summary: "後期高齢者医療で、やむを得ず全額自己負担したとき等の療養費の支給申請です。",
    fields: [
      { key: "treatmentDate", label: "診療を受けた日", type: "date", required: true },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "elderly-sousai", name: "葬祭費の支給申請（後期高齢者医療）", system: "ElderlyHealthcareSystem", category: "health",
    summary: "後期高齢者医療の被保険者が亡くなったときの葬祭費の支給申請です。",
    fields: [
      { key: "deceasedName", label: "亡くなられた方の氏名", type: "text", required: true },
      { key: "deathDate", label: "死亡年月日", type: "date", required: true },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "kaigo-jutaku", name: "介護保険 住宅改修費の支給申請", system: "LongTermCareInsuranceSystem_Web", category: "health",
    summary: "手すりの取付け等の住宅改修費の支給（償還払い）申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "workDetail", label: "改修の内容", type: "textarea", required: true },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "kaigo-fukushi", name: "介護保険 福祉用具購入費の支給申請", system: "LongTermCareInsuranceSystem_Web", category: "health",
    summary: "入浴・排せつ用の福祉用具購入費の支給（償還払い）申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "itemName", label: "購入した用具", type: "text", required: true },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "kaigo-saikofu", name: "介護保険被保険者証の再交付", system: "LongTermCareInsuranceSystem_Web", category: "health",
    summary: "介護保険被保険者証を紛失・破損したときの再交付申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "reason", label: "再交付の理由", type: "select", required: true, options: ["紛失", "破損", "その他"] },
    ],
  },
  // ── 福祉・くらし ──
  {
    id: "hosogu", name: "補装具費の支給申請", system: "ShogaishaFukushiSystem", category: "welfare",
    summary: "義肢・車いす・補聴器などの補装具費の支給申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "itemName", label: "希望する補装具", type: "text", required: true },
    ],
  },
  {
    id: "tokubetsu-shogai", name: "特別障害者手当・障害児福祉手当の申請", system: "ShogaishaFukushiSystem", category: "welfare",
    summary: "重度の障害により日常生活で常時介護を要する方への手当の申請です。",
    fields: [
      { key: "targetName", label: "対象者の氏名", type: "text", required: true },
      { key: "teateType", label: "手当の種類", type: "select", required: true, options: ["特別障害者手当", "障害児福祉手当"] },
      { key: "bankAccount", label: "振込先口座", type: "text", required: true },
    ],
  },
  {
    id: "maisou", name: "埋葬許可の申請", system: "cremation-permit-system", category: "welfare",
    summary: "土葬による埋葬の許可申請です（死亡届に伴うもの）。",
    fields: [
      { key: "deceasedName", label: "亡くなられた方の氏名", type: "text", required: true },
      { key: "deathDate", label: "死亡年月日", type: "date", required: true },
      { key: "place", label: "埋葬する場所", type: "text", required: true },
    ],
  },
  {
    id: "shitai-permit", name: "死産（死胎）の火葬・埋葬許可の申請", system: "cremation-permit-system", category: "welfare",
    summary: "死産届に伴う死胎の火葬・埋葬の許可申請です。",
    fields: [
      { key: "applyType", label: "種別", type: "select", required: true, options: ["火葬", "埋葬"] },
      { key: "stillbirthDate", label: "死産の年月日", type: "date", required: true },
      { key: "applicantRelation", label: "申請者の関係", type: "text", required: true },
    ],
  },
  // ── 税・年金 ──
  {
    id: "tax-kozabunkae", name: "市税の口座振替の申込", system: "tax-system-webapp", category: "tax",
    summary: "市税を口座振替で納付するための申込です。",
    fields: [
      { key: "taxKind", label: "対象の税目", type: "select", required: true, options: ["市・県民税", "固定資産税", "軽自動車税", "国民健康保険税", "すべて"] },
      { key: "bankAccount", label: "振替口座（銀行・支店・口座番号）", type: "text", required: true },
    ],
  },
  {
    id: "tax-denshi", name: "納税通知書の電子化（ペーパーレス）希望", system: "tax-system-webapp", category: "tax",
    summary: "納税通知書を郵送ではなく電子で受け取る希望の申請です。",
    fields: [
      { key: "email", label: "通知先メールアドレス", type: "text", required: true, placeholder: "例: taro@example.jp" },
    ],
  },
  {
    id: "tax-risai", name: "罹災証明の申請", system: "tax-system-webapp", category: "tax",
    summary: "災害により被害を受けた家屋等について、罹災の程度を証明する申請です。",
    fields: [
      { key: "damageDate", label: "被災した日", type: "date", required: true },
      { key: "damagePlace", label: "被災した家屋等の所在地", type: "text", required: true },
      { key: "damageDetail", label: "被害の状況", type: "textarea", required: true },
    ],
  },
  // ── 選挙・その他 ──
  {
    id: "yubin-vote", name: "郵便等投票証明書の交付申請", system: "electoral-roll-web", category: "other",
    summary: "重度の障害等で投票所に行けない方が、自宅で郵便により投票するための証明書の申請です。",
    fields: [
      { key: "reasonDetail", label: "該当する事由（障害の種類・程度など）", type: "textarea", required: true },
      { key: "contactTel", label: "連絡先電話番号", type: "tel", required: true },
    ],
  },
  {
    id: "zaigai-saikofu", name: "在外選挙人証の再交付申請", system: "electoral-roll-web", category: "other",
    summary: "在外選挙人証を紛失・破損したときの再交付申請です。",
    fields: [
      { key: "reason", label: "再交付の理由", type: "select", required: true, options: ["紛失", "破損", "記載事項変更", "その他"] },
      { key: "country", label: "現在の居住国・都市", type: "text", required: true },
    ],
  },
];

const mime = {
  ".html": "text/html; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".webmanifest": "application/manifest+json; charset=utf-8",
  ".svg": "image/svg+xml; charset=utf-8",
};

async function readJson(file, fallback) {
  try { return JSON.parse(MEM ? MEM.get(file) : await readFile(file, "utf8")); } catch { return fallback; }
}
// 申請状況の通知（市民向けリアルタイム通知）。状態が変わるたびに本人宛の通知を生成する。
const NOTIFY_MESSAGE = {
  RECEIVED: "申請を受け付けました。",
  REVIEWING: "審査を開始しました。",
  READY: "交付の準備が整いました。受け取り方法をご確認ください。",
  DELIVERED: "交付が完了しました。",
  REJECTED: "申請は受理されませんでした。詳細はお問い合わせください。",
};
async function pushNotification(app, status, kind = "cert") {
  if (!app || !app.residentId || !NOTIFY_MESSAGE[status]) return;
  const list = await readJson(notificationsFile, []);
  list.unshift({
    id: crypto.randomUUID(),
    residentId: app.residentId,
    applicationId: app.id,
    kind,
    receiptNumber: app.receiptNumber || null,
    typeName: app.typeName || "申請",
    status,
    statusLabel: STATUS_LABEL[status] || status,
    message: NOTIFY_MESSAGE[status],
    read: false,
    createdAt: new Date().toISOString(),
  });
  if (list.length > 200) list.length = 200;
  await writeJson(notificationsFile, list);
}

async function writeJson(file, value) {
  const text = JSON.stringify(value, null, 2);
  if (MEM) { MEM.set(file, text); return; }
  await mkdir(dirname(file), { recursive: true });
  await writeFile(file, text, "utf8");
}

// バックオフィス標準準拠システムへの申請連携（任意・ベストエフォート）。
// 環境変数 BACKOFFICE_BASES（{"slug":"http://host:port"} のJSON）が設定されていれば、
// 各システムの /link/application/inbound へ実際に申請を転送する。未設定なら QUEUED のまま。
const BACKOFFICE_BASES = (() => {
  try { return JSON.parse(process.env.BACKOFFICE_BASES || "{}"); } catch { return {}; }
})();
const LINK_TOKEN = process.env.PORTAL_LINK_TOKEN || "portal-link-dev-token";

async function forwardToBackoffice(slug, payload) {
  const base = BACKOFFICE_BASES[slug];
  if (!base) return { linkStatus: "QUEUED" }; // 連携先未設定: 受付のみ（職員が後続処理）
  const prefix = slug === "juki-web-reference" ? "/api/v1" : "/api";
  const url = `${base.replace(/\/$/, "")}${prefix}/link/application/inbound`;
  try {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 4000);
    const res = await fetch(url, {
      method: "POST",
      headers: { "content-type": "application/json", "x-link-token": LINK_TOKEN },
      body: JSON.stringify(payload),
      signal: ctrl.signal,
    });
    clearTimeout(timer);
    if (res.ok) {
      const data = await res.json().catch(() => ({}));
      return { linkStatus: "ACCEPTED", remoteId: data.id || data.eventId || null };
    }
    return { linkStatus: "ERROR" };
  } catch {
    return { linkStatus: "ERROR" };
  }
}

function json(res, status, body, extra = {}) {
  res.writeHead(status, {
    "content-type": "application/json; charset=utf-8",
    "cache-control": "no-store",
    "x-content-type-options": "nosniff",
    "x-frame-options": "DENY",
    "referrer-policy": "no-referrer",
    "x-request-id": crypto.randomUUID().slice(0, 12),
    ...extra,
  });
  res.end(JSON.stringify(body));
}

function readBody(req) {
  return new Promise((resolveBody) => {
    let raw = "";
    let tooBig = false;
    req.on("data", (chunk) => {
      raw += chunk;
      if (raw.length > 1_000_000) { tooBig = true; req.destroy(); }
    });
    req.on("end", () => {
      if (tooBig) return resolveBody({ __error: "TOO_LARGE" });
      try { resolveBody(raw ? JSON.parse(raw) : {}); } catch { resolveBody({}); }
    });
  });
}

function currentCitizen(req) {
  return verifyToken(bearer(req));
}

// 申請の状態遷移: RECEIVED(受付) → REVIEWING(審査中) → READY(交付準備完了) → DELIVERED(交付済)
const STATUS_LABEL = {
  RECEIVED: "受付", REVIEWING: "審査中", READY: "交付準備完了", DELIVERED: "交付済", CANCELLED: "取消", REJECTED: "却下",
};

function newReceiptNumber() {
  const d = new Date();
  const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
  return `R${ymd}-${crypto.randomInt(1000, 9999)}`;
}

async function serveStatic(req, res, pathname) {
  const target = pathname === "/" ? "/index.html" : pathname;
  const filePath = normalize(join(webRoot, target));
  if (!filePath.startsWith(webRoot) || !existsSync(filePath)) return false;
  const ext = extname(filePath);
  res.writeHead(200, {
    "content-type": mime[ext] || "application/octet-stream",
    "cache-control": "no-cache",
    // inline style属性をテンプレートで使用しているため style-src のみ 'unsafe-inline' を許容
    "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'",
    "x-content-type-options": "nosniff",
    "referrer-policy": "no-referrer",
  });
  res.end(readFileSync(filePath));
  return true;
}

async function handleApi(req, res, url) {
  const path = url.pathname.replace(/^\/api/, "");

  if (req.method === "GET" && (path === "/health" || url.pathname === "/health")) {
    return json(res, 200, { ok: true, service: "citizen-portal", time: new Date().toISOString() });
  }

  // ── マイナンバーカード認証（JPKIシミュレーション）──
  if (req.method === "POST" && path === "/auth/card-login") {
    const body = await readBody(req);
    if (body.__error) return json(res, 413, { error: "Request Entity Too Large" });
    const result = cardLogin(String(body.cardSerial || ""), String(body.pin || ""));
    if (!result.ok) return json(res, result.status, { error: result.error });
    return json(res, 200, { token: result.token, citizen: result.citizen });
  }
  if (req.method === "POST" && path === "/auth/logout") {
    logout(bearer(req));
    return json(res, 200, { ok: true });
  }
  if (req.method === "GET" && path === "/me") {
    const c = currentCitizen(req);
    if (!c) return json(res, 401, { error: "ログインが必要です。" });
    return json(res, 200, c);
  }

  // ── 証明書の種類カタログ ──
  if (req.method === "GET" && path === "/certificate-types") {
    return json(res, 200, CERTIFICATE_TYPES.map((t) => ({ id: t.id, name: t.name, unitFee: t.unitFee, delivery: t.delivery, note: t.note })));
  }

  // ── 制度の試算（市民向けクイック見積もり）──
  // 計算のみで個人情報は保存しないため、ログイン不要で利用できる。
  if (req.method === "GET" && path === "/estimate-types") {
    return json(res, 200, estimatorCatalog());
  }
  if (req.method === "POST" && path === "/estimate") {
    const body = await readBody(req);
    if (body.__error) return json(res, 413, { error: "Request Entity Too Large" });
    const result = runEstimate(body.id, body.input || {});
    if (result.error) return json(res, 400, result);
    return json(res, 200, result);
  }

  // ── 申請状況の通知（本人宛・未読管理）──
  if (req.method === "GET" && path === "/notifications") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const list = await readJson(notificationsFile, []);
    const mine = list.filter((n) => n.residentId === citizen.residentId);
    return json(res, 200, { unread: mine.filter((n) => !n.read).length, items: mine });
  }
  if (req.method === "POST" && path === "/notifications/read") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const list = await readJson(notificationsFile, []);
    let marked = 0;
    for (const n of list) { if (n.residentId === citizen.residentId && !n.read) { n.read = true; marked++; } }
    if (marked) await writeJson(notificationsFile, list);
    return json(res, 200, { ok: true, marked });
  }

  // ── 証明書オンライン請求 ──
  if (req.method === "POST" && path === "/certificate-requests") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const body = await readBody(req);
    if (body.__error) return json(res, 413, { error: "Request Entity Too Large" });
    const type = CERTIFICATE_TYPES.find((t) => t.id === body.typeId);
    if (!type) return json(res, 400, { error: "証明書の種類が不正です。" });
    const copies = Math.max(1, Math.min(10, Number(body.copies) || 1));
    const deliveryMethod = type.delivery.includes(body.deliveryMethod) ? body.deliveryMethod : type.delivery[0];
    const fee = type.unitFee * copies + (deliveryMethod === "POSTAL" ? POSTAL_FEE : 0);
    const now = new Date().toISOString();
    const apps = await readJson(applicationsFile, []);
    const app = {
      id: crypto.randomUUID(),
      receiptNumber: newReceiptNumber(),
      residentId: citizen.residentId,
      applicantName: `${citizen.familyNameKanji}${citizen.givenNameKanji}`,
      typeId: type.id,
      typeName: type.name,
      copies,
      deliveryMethod,
      usage: String(body.usage || "").slice(0, 200),
      fee,
      status: "RECEIVED",
      statusLabel: STATUS_LABEL.RECEIVED,
      // バックオフィス標準準拠システムへの連携情報
      routedTo: type.system,
      linkStatus: "QUEUED",
      submittedAt: now,
      updatedAt: now,
      history: [{ status: "RECEIVED", at: now }],
    };
    const fwd = await forwardToBackoffice(type.system, {
      source: "citizen-portal", externalId: app.id, receiptNumber: app.receiptNumber,
      procedureType: `certificate:${type.id}`, applicant: { residentId: citizen.residentId, name: app.applicantName },
      payload: { typeName: type.name, copies, deliveryMethod, fee },
    });
    app.linkStatus = fwd.linkStatus;
    if (fwd.remoteId) app.remoteId = fwd.remoteId;
    apps.unshift(app);
    await writeJson(applicationsFile, apps);
    return json(res, 201, app);
  }

  if (req.method === "GET" && path === "/certificate-requests") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const apps = await readJson(applicationsFile, []);
    return json(res, 200, apps.filter((a) => a.residentId === citizen.residentId));
  }

  const reqMatch = path.match(/^\/certificate-requests\/([^/]+)$/);
  if (reqMatch && req.method === "GET") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const apps = await readJson(applicationsFile, []);
    const app = apps.find((a) => a.id === reqMatch[1]);
    if (!app || app.residentId !== citizen.residentId) return json(res, 404, { error: "申請が見つかりません。" });
    return json(res, 200, app);
  }

  const cancelMatch = path.match(/^\/certificate-requests\/([^/]+)\/cancel$/);
  if (cancelMatch && req.method === "POST") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const apps = await readJson(applicationsFile, []);
    const app = apps.find((a) => a.id === cancelMatch[1]);
    if (!app || app.residentId !== citizen.residentId) return json(res, 404, { error: "申請が見つかりません。" });
    if (["DELIVERED", "CANCELLED", "REJECTED"].includes(app.status)) {
      return json(res, 409, { error: "この申請は取消できません。" });
    }
    app.status = "CANCELLED";
    app.statusLabel = STATUS_LABEL.CANCELLED;
    app.updatedAt = new Date().toISOString();
    app.history.push({ status: "CANCELLED", at: app.updatedAt });
    await writeJson(applicationsFile, apps);
    return json(res, 200, app);
  }

  // ── バックオフィス連携（審査側）: 申請の状態を進める（窓口/審査担当用の模擬API）──
  const advanceMatch = path.match(/^\/certificate-requests\/([^/]+)\/advance$/);
  if (advanceMatch && req.method === "POST") {
    // 本実装では認可を簡略化（実機ではバックオフィス職員の権限が必要）
    const apps = await readJson(applicationsFile, []);
    const app = apps.find((a) => a.id === advanceMatch[1]);
    if (!app) return json(res, 404, { error: "申請が見つかりません。" });
    const flow = { RECEIVED: "REVIEWING", REVIEWING: "READY", READY: "DELIVERED" };
    const next = flow[app.status];
    if (!next) return json(res, 409, { error: "これ以上進められません。", status: app.status });
    app.status = next;
    app.statusLabel = STATUS_LABEL[next];
    if (next === "REVIEWING") app.linkStatus = "ACCEPTED";
    app.updatedAt = new Date().toISOString();
    app.history.push({ status: next, at: app.updatedAt });
    await writeJson(applicationsFile, apps);
    await pushNotification(app, next);
    return json(res, 200, app);
  }

  // ── 第2弾以降: 届出・申請手続き（証明書以外）──
  if (req.method === "GET" && path === "/procedure-types") {
    return json(res, 200, PROCEDURE_TYPES.map((t) => ({ id: t.id, name: t.name, summary: t.summary, fields: t.fields, category: t.category || "other", categoryLabel: CATEGORY_LABELS[t.category] || "その他" })));
  }

  if (req.method === "POST" && path === "/procedure-applications") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const body = await readBody(req);
    if (body.__error) return json(res, 413, { error: "Request Entity Too Large" });
    const type = PROCEDURE_TYPES.find((t) => t.id === body.typeId);
    if (!type) return json(res, 400, { error: "手続きの種類が不正です。" });
    const payload = {};
    for (const f of type.fields) {
      const v = body.payload && typeof body.payload === "object" ? body.payload[f.key] : undefined;
      const val = (v == null ? "" : String(v)).slice(0, 1000).trim();
      if (f.required && !val) return json(res, 400, { error: `「${f.label}」を入力してください。` });
      payload[f.key] = val;
    }
    const now = new Date().toISOString();
    const apps = await readJson(proceduresFile, []);
    const app = {
      id: crypto.randomUUID(),
      receiptNumber: newReceiptNumber(),
      residentId: citizen.residentId,
      applicantName: `${citizen.familyNameKanji}${citizen.givenNameKanji}`,
      typeId: type.id,
      typeName: type.name,
      payload,
      status: "RECEIVED",
      statusLabel: STATUS_LABEL.RECEIVED,
      routedTo: type.system,
      linkStatus: "QUEUED",
      submittedAt: now,
      updatedAt: now,
      history: [{ status: "RECEIVED", at: now }],
    };
    const fwd = await forwardToBackoffice(type.system, {
      source: "citizen-portal", externalId: app.id, receiptNumber: app.receiptNumber,
      procedureType: app.typeId, applicant: { residentId: citizen.residentId, name: app.applicantName },
      payload: app.payload,
    });
    app.linkStatus = fwd.linkStatus;
    if (fwd.remoteId) app.remoteId = fwd.remoteId;
    apps.unshift(app);
    await writeJson(proceduresFile, apps);
    return json(res, 201, app);
  }

  if (req.method === "GET" && path === "/procedure-applications") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const apps = await readJson(proceduresFile, []);
    return json(res, 200, apps.filter((a) => a.residentId === citizen.residentId));
  }

  const procGet = path.match(/^\/procedure-applications\/([^/]+)$/);
  if (procGet && req.method === "GET") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const apps = await readJson(proceduresFile, []);
    const app = apps.find((a) => a.id === procGet[1]);
    if (!app || app.residentId !== citizen.residentId) return json(res, 404, { error: "申請が見つかりません。" });
    return json(res, 200, app);
  }

  const procCancel = path.match(/^\/procedure-applications\/([^/]+)\/cancel$/);
  if (procCancel && req.method === "POST") {
    const citizen = currentCitizen(req);
    if (!citizen) return json(res, 401, { error: "ログインが必要です。" });
    const apps = await readJson(proceduresFile, []);
    const app = apps.find((a) => a.id === procCancel[1]);
    if (!app || app.residentId !== citizen.residentId) return json(res, 404, { error: "申請が見つかりません。" });
    if (["DELIVERED", "CANCELLED", "REJECTED"].includes(app.status)) return json(res, 409, { error: "この申請は取消できません。" });
    app.status = "CANCELLED"; app.statusLabel = STATUS_LABEL.CANCELLED;
    app.updatedAt = new Date().toISOString();
    app.history.push({ status: "CANCELLED", at: app.updatedAt });
    await writeJson(proceduresFile, apps);
    return json(res, 200, app);
  }

  const procAdvance = path.match(/^\/procedure-applications\/([^/]+)\/advance$/);
  if (procAdvance && req.method === "POST") {
    const apps = await readJson(proceduresFile, []);
    const app = apps.find((a) => a.id === procAdvance[1]);
    if (!app) return json(res, 404, { error: "申請が見つかりません。" });
    const flow = { RECEIVED: "REVIEWING", REVIEWING: "READY", READY: "DELIVERED" };
    const next = flow[app.status];
    if (!next) return json(res, 409, { error: "これ以上進められません。", status: app.status });
    app.status = next; app.statusLabel = STATUS_LABEL[next];
    if (next === "REVIEWING") app.linkStatus = "ACCEPTED";
    app.updatedAt = new Date().toISOString();
    app.history.push({ status: next, at: app.updatedAt });
    await writeJson(proceduresFile, apps);
    await pushNotification(app, next, "proc");
    return json(res, 200, app);
  }

  // ── バックオフィスからの処理状況コールバック受信 ──
  // 担当システムが申請の状態を進めたとき、x-link-token 付きでここへ通知し、
  // 市民の申請（証明書請求・届出）の状態に反映する。externalId = 当ポータルの申請ID。
  if (req.method === "POST" && path === "/link/callback") {
    if (req.headers["x-link-token"] !== LINK_TOKEN) return json(res, 401, { error: "link token が不正です。" });
    const body = await readBody(req);
    if (body.__error) return json(res, 413, { error: "Request Entity Too Large" });
    const externalId = String(body.externalId || "");
    // バックオフィスの状態 → 市民向け状態へのマッピング
    const map = { RECEIVED: "RECEIVED", PROCESSING: "REVIEWING", COMPLETED: "READY", REJECTED: "REJECTED" };
    const next = map[String(body.status || "")];
    if (!externalId || !next) return json(res, 400, { error: "externalId と status は必須です。" });
    const now = new Date().toISOString();
    let updated = null;
    for (const file of [applicationsFile, proceduresFile]) {
      const apps = await readJson(file, []);
      const app = apps.find((a) => a.id === externalId);
      if (!app) continue;
      if (!["CANCELLED", "DELIVERED"].includes(app.status)) {
        app.status = next;
        app.statusLabel = STATUS_LABEL[next];
        app.linkStatus = "ACCEPTED";
        app.updatedAt = now;
        app.history.push({ status: next, at: now });
        await writeJson(file, apps);
        await pushNotification(app, next, file === applicationsFile ? "cert" : "proc");
      }
      updated = { id: app.id, status: app.status, statusLabel: app.statusLabel };
      break;
    }
    if (!updated) return json(res, 404, { error: "対象の申請が見つかりません。" });
    return json(res, 200, { ok: true, ...updated });
  }

  return json(res, 404, { error: "APIが見つかりません。" });
}

export const server = createServer((req, res) => {
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
  if (url.pathname === "/health" || url.pathname.startsWith("/api/")) {
    handleApi(req, res, url).catch((e) => json(res, 500, { error: String(e) }));
    return;
  }
  serveStatic(req, res, url.pathname).then((served) => {
    if (!served) serveStatic(req, res, "/index.html"); // SPA フォールバック
  }).catch(() => json(res, 500, { error: "static error" }));
});

const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;

if (process.argv.includes("--smoke")) {
  const r = cardLogin("JPKI-0001", "1234");
  if (!r.ok) throw new Error("Smoke failed: card-login should succeed");
  const bad = cardLogin("JPKI-0001", "0000");
  if (bad.ok) throw new Error("Smoke failed: wrong pin should fail");
  if (!verifyToken(r.token)) throw new Error("Smoke failed: token should verify");
  if (MOCK_CITIZENS.length < 1) throw new Error("Smoke failed: citizens seed");
  console.log("Smoke test passed.");
} else if (isMain) {
  server.listen(port, () => console.log(`市民オンライン手続きポータル: http://localhost:${port}`));
}
