// electoral-roll-web — 固有ロジック（共通coreへ委譲される per-system handle）
import { join } from "node:path";

// ── 選挙人名簿 登録資格の判定（公職選挙法: 満18歳以上の日本国民、引き続き3か月以上住所を有する）──
function monthsBetween(from, to) {
  const f = new Date(from), t = new Date(to);
  let m = (t.getFullYear() - f.getFullYear()) * 12 + (t.getMonth() - f.getMonth());
  if (t.getDate() < f.getDate()) m -= 1;
  return m;
}
function ageAt(birthDate, at) {
  const b = new Date(birthDate), a = new Date(at);
  let age = a.getFullYear() - b.getFullYear();
  if (a.getMonth() < b.getMonth() || (a.getMonth() === b.getMonth() && a.getDate() < b.getDate())) age -= 1;
  return age;
}
function computeElectoralEligibility({ birthDate, addressFixedDate, isJapanese = true, asOf }) {
  const at = asOf ? new Date(asOf) : new Date();
  const age = birthDate ? ageAt(birthDate, at) : null;
  const residencyMonths = addressFixedDate ? monthsBetween(addressFixedDate, at) : null;
  const reasons = [];
  if (!isJapanese) reasons.push("日本国民でない");
  if (age == null || age < 18) reasons.push("満18歳未満");
  if (residencyMonths == null || residencyMonths < 3) reasons.push("住所要件（引き続き3か月以上）を満たさない");
  return { eligible: reasons.length === 0, age, residencyMonths, reasons };
}

export async function handle(ctx) {
  const { req, res, pathname, searchParams, actor, send, sendRaw, parseBody, readJson, writeJson, audit,
    maskRecord, restrictionApplies, validateRecord, requireRole, hasRole, dataRoot, restrictionsFile, crypto,
    dbSelect, dbSelectOne, dbInsert, dbUpdate, dbDelete } = ctx;
  const reply = (...a) => { send(...a); return true; };
  const votersFile = join(dataRoot, "voters.json");
  const proxyWitnessesFile = join(dataRoot, "proxy-witnesses.json");
  const voterEventsFile = join(dataRoot, "voter-events.json");
  const electionCardsFile = join(dataRoot, "election-cards.json");
  const electionVotesFile = join(dataRoot, "election-votes.json");
  const wardConfigFile = join(dataRoot, "ward-config.json");


    // ── 選挙人 (Voters) ───────────────────────────────────────────────────

    if (pathname === "/api/voters" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const voters = (await dbSelect("electoral.voter_registration")) ?? await readJson(votersFile, []);
      const restrictions = await readJson(restrictionsFile, []);
      const status = searchParams.get("status");
      let result = voters;
      if (status) result = result.filter((v) => v.status === status);
      return reply(res, 200, result.map((v) => {
        const applied = restrictions.filter((r) => restrictionApplies(r, { id: v.id, domain: "voter" }));
        if (!applied.length || hasRole(actor, "SUPPORT_ACCESS")) return v;
        return { id: v.id, status: v.status, restricted: true, fullName: "（支援措置対象）", address: null, birthDate: null, registrationNumber: v.registrationNumber, createdAt: v.createdAt };
      }));
    }

    if (pathname === "/api/voters" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      if (!body.fullName || !body.address || !body.birthDate) { const e = new Error("fullName, address and birthDate are required"); e.statusCode = 400; throw e; }
      const voters = (await dbSelect("electoral.voter_registration")) ?? await readJson(votersFile, []);
      const item = {
        id: crypto.randomUUID(),
        registrationNumber: "SR-" + Date.now().toString(36).toUpperCase(),
        fullName: String(body.fullName),
        address: String(body.address),
        birthDate: String(body.birthDate),
        district: body.district !== undefined ? String(body.district) : "", // 選挙区（複数選挙区・抄本抽出用）
        ward: body.ward !== undefined ? String(body.ward) : "", // 指定都市の総合区・行政区（行政区別出力・移替え用）
        status: "registered",
        registrationDate: body.registrationDate ?? new Date().toISOString().slice(0, 10),
        notes: body.notes ?? "",
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
        createdBy: actor.id,
      };
      const dbInserted = await dbInsert("electoral.voter_registration", item); if (dbInserted === null) { voters.unshift(item); await writeJson(votersFile, voters); }
      await audit(req, "voter.register", { id: item.id, registrationNumber: item.registrationNumber });
      return reply(res, 201, item);
    }

    const voterMatch = pathname.match(/^\/api\/voters\/([^/]+)$/);
    if (voterMatch) {
      requireRole(actor, "CLERK");
      const voters = (await dbSelect("electoral.voter_registration")) ?? await readJson(votersFile, []);
      const index = voters.findIndex((v) => v.id === voterMatch[1]);
      if (index === -1) return reply(res, 404, { error: "voter not found" });
      if (req.method === "GET") return reply(res, 200, voters[index]);
      if (req.method === "PUT") {
        const body = await parseBody(req);
        const item = { ...voters[index], fullName: body.fullName ?? voters[index].fullName, address: body.address ?? voters[index].address, status: body.status ?? voters[index].status, notes: body.notes ?? voters[index].notes, updatedAt: new Date().toISOString(), updatedBy: actor.id };
        const upd = await dbUpdate("electoral.voter_registration", item.id, item);
        if (upd === null) { voters[index] = item; await writeJson(votersFile, voters); }
        await audit(req, "voter.update", { id: item.id });
        return reply(res, 200, item);
      }
      // 誤って受け付けてしまった選挙人の管理（削除）＝物理削除（受付の取消）
      if (req.method === "DELETE") {
        const removed = voters[index];
        const del = await dbDelete?.("electoral.voter_registration", removed.id);
        if (del === null || del === undefined) { voters.splice(index, 1); await writeJson(votersFile, voters); }
        await audit(req, "voter.delete", { id: removed.id, reason: "誤受付の取消" });
        return reply(res, 200, { deleted: true, id: removed.id });
      }
    }

    // ── 選挙人名簿異動 (Voter Registration Events) ────────────────────────

    const voterEventsMatch = pathname.match(/^\/api\/voters\/([^/]+)\/events$/);
    if (voterEventsMatch) {
      requireRole(actor, "CLERK");
      const voterId = voterEventsMatch[1];
      const voters = (await dbSelect("electoral.voter_registration")) ?? await readJson(votersFile, []);
      if (!voters.find((v) => v.id === voterId)) return reply(res, 404, { error: "voter not found" });
      const events = (await dbSelect("electoral.voter_event")) ?? await readJson(voterEventsFile, []);

      if (req.method === "GET") return reply(res, 200, events.filter((e) => e.voterId === voterId));

      if (req.method === "POST") {
        const body = await parseBody(req);
        const validTypes = ["register", "remove", "address_change", "transfer_in", "transfer_out", "suspend", "reinstate"];
        if (!body.eventType || !validTypes.includes(body.eventType)) { const e = new Error(`eventType must be one of: ${validTypes.join(", ")}`); e.statusCode = 400; throw e; }
        if (!body.effectiveDate) { const e = new Error("effectiveDate is required"); e.statusCode = 400; throw e; }
        const item = { id: crypto.randomUUID(), voterId, eventType: body.eventType, effectiveDate: String(body.effectiveDate), reason: body.reason ?? null, notes: body.notes ?? "", createdAt: new Date().toISOString(), createdBy: actor.id };
        if (body.eventType === "remove" || body.eventType === "transfer_out") {
          const vIdx = voters.findIndex((v) => v.id === voterId);
          if (vIdx !== -1) { voters[vIdx] = { ...voters[vIdx], status: "removed", updatedAt: new Date().toISOString(), updatedBy: actor.id }; await writeJson(votersFile, voters); }
        } else if (body.eventType === "suspend") {
          const vIdx = voters.findIndex((v) => v.id === voterId);
          if (vIdx !== -1) { voters[vIdx] = { ...voters[vIdx], status: "suspended", updatedAt: new Date().toISOString(), updatedBy: actor.id }; await writeJson(votersFile, voters); }
        }
        const dbInserted = await dbInsert("electoral.voter_event", item); if (dbInserted === null) { events.unshift(item); await writeJson(voterEventsFile, events); }
        await audit(req, `voter.${body.eventType}`, { id: item.id, voterId, effectiveDate: item.effectiveDate });
        return reply(res, 201, item);
      }
    }


    const voterDeleteMatch = pathname.match(/^\/api\/voters\/([^\/]+)$/);
    if (voterDeleteMatch && req.method === "DELETE") {
      requireRole(actor, "ADMIN");
      const delId = voterDeleteMatch[1];
      const deleted = await dbDelete("electoral.voter_registration", delId);
      if (deleted === null) {
        const _items = await readJson(votersFile, []);
        const _idx = _items.findIndex((x) => x.id === delId);
        if (_idx === -1) return reply(res, 404, { error: "voter not found" });
        _items.splice(_idx, 1);
        await writeJson(votersFile, _items);
      }
      await audit(req, "voter.delete", { id: delId });
      return reply(res, 204, {});
    }


    // ── 選挙人名簿 登録資格の判定（公職選挙法準拠）──
    if (pathname === "/api/electoral/eligibility" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const body = await parseBody(req);
      const result = computeElectoralEligibility(body || {});
      await audit(req, "electoral.eligibility", { eligible: result.eligible });
      return reply(res, 200, result);
    }

    // ════ 投票受付・入場券管理（標準仕様書: 入場券様式/期日前・不在者・当日受付/名簿対照/投票録/複数選挙）════

    // ── 投票所入場券 ──
    // 様式: はがき個人票（圧着有/圧着無）・はがき世帯票（2人）・封書世帯。文言は15文字以内で変更可。
    // 裏面に期日前・不在者投票の宣誓書（兼請求書）を出力できる（oathOnBack）。
    const CARD_FORMS = ["はがき個人圧着", "はがき個人", "はがき世帯2人", "封書世帯"];
    if (pathname === "/api/election/cards" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const cards = await readJson(electionCardsFile, []);
      const electionId = searchParams.get("electionId") ?? "";
      return reply(res, 200, electionId ? cards.filter((c) => c.electionId === electionId) : cards.slice(0, 500));
    }
    if ((pathname === "/api/election/cards" || pathname === "/api/election/cards/bulk") && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      if (!b.electionId) return reply(res, 400, { error: "electionId required" });
      const form = CARD_FORMS.includes(b.form) ? b.form : "はがき個人圧着";
      const title = String(b.title ?? "投票所入場券");
      if (title.length > 15) return reply(res, 400, { error: "入場券の文言は15文字以内です" });
      const voters = await readJson(votersFile, []);
      const targets = pathname.endsWith("/bulk")
        ? (Array.isArray(b.voterIds) && b.voterIds.length ? voters.filter((v) => b.voterIds.includes(v.id)) : voters)
        : voters.filter((v) => v.id === b.voterId);
      if (!targets.length) return reply(res, 404, { error: "対象の選挙人がいません" });
      const cards = await readJson(electionCardsFile, []);
      const issued = targets.map((v) => ({
        id: crypto.randomUUID(), electionId: String(b.electionId), voterId: v.id, voterName: v.fullName ?? "",
        district: v.district ?? "", form, title, oathOnBack: b.oathOnBack !== false,
        barcode: `${b.electionId}:${v.id}`, status: "発行済", issuedBy: actor.id, issuedAt: new Date().toISOString(),
      }));
      cards.unshift(...issued);
      await writeJson(electionCardsFile, cards);
      await audit(req, "election-card.issue", { electionId: b.electionId, form, count: issued.length });
      return reply(res, 201, pathname.endsWith("/bulk") ? { count: issued.length, form, oathOnBack: b.oathOnBack !== false } : issued[0]);
    }
    {
      const cardOpMatch = pathname.match(/^\/api\/election\/cards\/([^/]+)\/(return|reissue)$/);
      if (cardOpMatch && req.method === "POST") {
        requireRole(actor, "CLERK");
        const cards = await readJson(electionCardsFile, []);
        const idx = cards.findIndex((c) => c.id === cardOpMatch[1]);
        if (idx === -1) return reply(res, 404, { error: "card not found" });
        if (cardOpMatch[2] === "return") {
          cards[idx].status = "返還";
          cards[idx].returnedAt = new Date().toISOString();
          await writeJson(electionCardsFile, cards);
          await audit(req, "election-card.return", { id: cards[idx].id });
          return reply(res, 200, cards[idx]);
        }
        // 再発行（不在者投票の再交付を含む）。元券は失効として新券を発行
        cards[idx].status = "失効";
        const reissued = { ...cards[idx], id: crypto.randomUUID(), status: "再発行", reissuedFrom: cards[idx].id, issuedAt: new Date().toISOString(), issuedBy: actor.id };
        cards.unshift(reissued);
        await writeJson(electionCardsFile, cards);
        await audit(req, "election-card.reissue", { from: cardOpMatch[1], to: reissued.id });
        return reply(res, 201, reissued);
      }
    }

    // ── 投票受付（期日前・不在者・当日）。二重投票を防止しつつ、返還処理後の受付・再交付に対応 ──
    //    electionId 単位で独立管理＝同日選挙や期日前期間が重複する複数選挙を同一端末（同一システム）で受付できる。
    if (pathname === "/api/election/votes" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      const kinds = ["期日前", "不在者", "当日"];
      if (!b.electionId || !b.voterId || !kinds.includes(b.kind)) {
        return reply(res, 400, { error: `electionId, voterId, kind(${kinds.join("/")}) が必須です` });
      }
      const votes = await readJson(electionVotesFile, []);
      const dup = votes.find((v) => v.electionId === b.electionId && v.voterId === b.voterId);
      if (dup) return reply(res, 409, { error: "二重投票", existing: { kind: dup.kind, at: dup.at } });
      const vote = {
        id: crypto.randomUUID(), electionId: String(b.electionId), voterId: String(b.voterId),
        kind: b.kind, station: b.station ?? "第1投票所", declaration: b.kind !== "当日", // 期日前・不在者は宣誓書（兼請求書）
        at: new Date().toISOString(), by: actor.id,
      };
      votes.unshift(vote);
      await writeJson(electionVotesFile, votes);
      await audit(req, "election-vote.accept", { electionId: vote.electionId, kind: vote.kind });
      return reply(res, 201, vote);
    }
    // 名簿対照: 外封筒・入場券のバーコード読取（electionId:voterId）→ 選挙人と投票状況を返す
    if (pathname === "/api/election/votes/lookup" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      const [electionId, voterId] = String(b.barcode ?? "").split(":");
      if (!electionId || !voterId) return reply(res, 400, { error: "barcode(electionId:voterId) required" });
      const voters = await readJson(votersFile, []);
      const voter = voters.find((v) => v.id === voterId);
      if (!voter) return reply(res, 404, { error: "voter not found" });
      const votes = await readJson(electionVotesFile, []);
      const voted = votes.find((v) => v.electionId === electionId && v.voterId === voterId) ?? null;
      await audit(req, "election-roll.lookup", { electionId, voterId });
      return reply(res, 200, { voter: { id: voter.id, fullName: voter.fullName, district: voter.district ?? "" }, voted });
    }

    // ── 選挙人名簿の検索（名簿番号の直接入力／漢字氏名／カナ氏名／生年月日、又はその組み合わせ）──
    if (pathname === "/api/election/roll/search" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const rosterNo = (searchParams.get("名簿番号") ?? searchParams.get("rosterNo") ?? "").trim();
      const kanji = (searchParams.get("漢字氏名") ?? searchParams.get("kanjiName") ?? "").trim();
      const kana = (searchParams.get("カナ氏名") ?? searchParams.get("kanaName") ?? "").trim();
      const birth = (searchParams.get("生年月日") ?? searchParams.get("birthDate") ?? "").trim();
      if (!rosterNo && !kanji && !kana && !birth) {
        return reply(res, 400, { error: "名簿番号/漢字氏名/カナ氏名/生年月日 のいずれか（又は組み合わせ）を指定してください" });
      }
      const voters = await readJson(votersFile, []);
      const norm = (s) => String(s ?? "");
      const hits = voters.filter((v) => {
        if (rosterNo && norm(v.rosterNo ?? v.名簿番号 ?? v.registrationNumber ?? v.id) !== rosterNo) return false; // 名簿番号は直接入力（完全一致）
        if (kanji && !norm(v.name ?? v.fullName ?? v.kanjiName).includes(kanji)) return false; // 漢字氏名は部分一致
        if (kana && !norm(v.kana ?? v.kanaName ?? v.fullNameKana).includes(kana)) return false; // カナ氏名は部分一致
        if (birth && norm(v.birthDate ?? v.生年月日) !== birth) return false; // 生年月日は完全一致
        return true;
      });
      await audit(req, "election-roll.search", { rosterNo: !!rosterNo, kanji: !!kanji, kana: !!kana, birth: !!birth, count: hits.length });
      return reply(res, 200, { criteria: { 名簿番号: rosterNo, 漢字氏名: kanji, カナ氏名: kana, 生年月日: birth }, count: hits.length, voters: hits });
    }

    // ── 代理投票の立会人・補助者の管理（登録・修正・削除）──
    //   投票日・投票方法（通常投票/点字投票/代理投票）・仮投票と紐づけ、代理投票時の立会人・補助者を記録。
    if (pathname === "/api/election/proxy-witnesses" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const voterId = searchParams.get("voterId") ?? "";
      const list = (await readJson(proxyWitnessesFile, [])).filter((w) => !voterId || w.voterId === voterId);
      return reply(res, 200, { count: list.length, witnesses: list });
    }
    if (pathname === "/api/election/proxy-witnesses" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      if (!b.役割 || !["立会人", "補助者"].includes(b.役割)) return reply(res, 400, { error: "役割(立会人/補助者) が必要です" });
      if (!b.氏名) return reply(res, 400, { error: "氏名 が必要です" });
      const list = await readJson(proxyWitnessesFile, []);
      const item = { id: crypto.randomUUID(), voterId: b.voterId ?? null, 投票日: b.投票日 ?? null,
        投票方法: b.投票方法 ?? "代理投票", 仮投票: b.仮投票 === true, 役割: b.役割, 氏名: String(b.氏名),
        備考: b.備考 ?? "", createdAt: new Date().toISOString(), createdBy: actor.id };
      list.unshift(item); await writeJson(proxyWitnessesFile, list);
      await audit(req, "election-proxy-witness.create", { id: item.id, 役割: item.役割 });
      return reply(res, 201, item);
    }
    const witnessMatch = pathname.match(/^\/api\/election\/proxy-witnesses\/([^/]+)$/);
    if (witnessMatch) {
      requireRole(actor, "CLERK");
      const list = await readJson(proxyWitnessesFile, []);
      const idx = list.findIndex((w) => w.id === witnessMatch[1]);
      if (idx === -1) return reply(res, 404, { error: "witness not found" });
      if (req.method === "PUT") { // 修正
        const b = await parseBody(req);
        const { id, createdAt, ...patch } = b;
        list[idx] = { ...list[idx], ...patch, updatedAt: new Date().toISOString(), updatedBy: actor.id };
        await writeJson(proxyWitnessesFile, list);
        await audit(req, "election-proxy-witness.update", { id: list[idx].id });
        return reply(res, 200, list[idx]);
      }
      if (req.method === "DELETE") { // 削除
        const removed = list.splice(idx, 1)[0];
        await writeJson(proxyWitnessesFile, list);
        await audit(req, "election-proxy-witness.delete", { id: removed.id });
        return reply(res, 200, { deleted: true, id: removed.id });
      }
    }

    // 投票録（共通投票所投票録を含む）: 選挙×投票所別に種別集計
    if (pathname === "/api/election/votes/record" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const electionId = searchParams.get("electionId") ?? "";
      if (!electionId) return reply(res, 400, { error: "electionId required" });
      const station = searchParams.get("station") ?? "";
      const votes = (await readJson(electionVotesFile, [])).filter((v) => v.electionId === electionId && (!station || v.station === station));
      const byKind = {};
      for (const v of votes) byKind[v.kind] = (byKind[v.kind] ?? 0) + 1;
      return reply(res, 200, { electionId, station: station || "全投票所", total: votes.length, byKind, createdAt: new Date().toISOString() });
    }
    // 一部選挙区のみの抄本（複数選挙区・補欠選挙対応）。行政区別出力・部数別発行番号にも対応
    if (pathname === "/api/election/roll/extract" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const district = searchParams.get("district") ?? "";
      const ward = searchParams.get("ward") ?? "";
      const copies = Math.min(Math.max(Number(searchParams.get("copies") ?? 1), 1), 50);
      const voters = await readJson(votersFile, []);
      let extract = voters;
      if (district) extract = extract.filter((v) => (v.district ?? "") === district);
      if (ward) extract = extract.filter((v) => (v.ward ?? "") === ward);
      // 複数部数の発行は一部ずつ異なる発行番号を付す
      const issueNumbers = Array.from({ length: copies }, () => `EX-${crypto.randomUUID().slice(0, 8).toUpperCase()}`);
      await audit(req, "election-roll.extract", { district: district || "全域", ward: ward || "全区", copies, count: extract.length });
      return reply(res, 200, { district: district || "全域", ward: ward || "全区", count: extract.length,
        copies, issueNumbers, lineNumberPosition: searchParams.get("lineNumbers") === "right" ? "right" : "left", voters: extract });
    }

    // ── 指定都市の行政区・総合区対応 ──
    // 17歳名簿（行政区別出力可。18歳到達前＝選挙権年齢到達予定者の名簿）
    if (pathname === "/api/election/roll/age17" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const asOf = new Date(searchParams.get("asOf") ?? new Date().toISOString().slice(0, 10));
      const ward = searchParams.get("ward") ?? "";
      const voters = await readJson(votersFile, []);
      const list = voters.filter((v) => {
        if (!v.birthDate) return false;
        if (ward && (v.ward ?? "") !== ward) return false;
        const b = new Date(v.birthDate);
        let age = asOf.getFullYear() - b.getFullYear();
        if (asOf.getMonth() < b.getMonth() || (asOf.getMonth() === b.getMonth() && asOf.getDate() < b.getDate())) age -= 1;
        return age === 17;
      });
      await audit(req, "election-roll.age17", { ward: ward || "全区", count: list.length });
      return reply(res, 200, { ward: ward || "全区", count: list.length, voters: list });
    }
    // 移替えの停止/再開（行政区・総合区を選択して停止できる）
    if (pathname === "/api/election/ward-transfer/stop" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      if (!b.ward) return reply(res, 400, { error: "ward required" });
      const config = await readJson(wardConfigFile, {});
      config[String(b.ward)] = { transferStopped: b.stopped !== false, at: new Date().toISOString(), by: actor.id };
      await writeJson(wardConfigFile, config);
      await audit(req, "election-ward.transfer-stop", { ward: b.ward, stopped: b.stopped !== false });
      return reply(res, 200, { ward: b.ward, transferStopped: b.stopped !== false });
    }
    // 移替えの実行（pendingWard を ward へ反映。停止中の行政区は除外）
    if (pathname === "/api/election/ward-transfer/run" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const config = await readJson(wardConfigFile, {});
      const voters = await readJson(votersFile, []);
      let moved = 0;
      const skipped = [];
      for (const v of voters) {
        if (!v.pendingWard) continue;
        if (config[v.pendingWard]?.transferStopped) { skipped.push(v.id); continue; }
        v.ward = v.pendingWard;
        delete v.pendingWard;
        moved += 1;
      }
      await writeJson(votersFile, voters);
      await audit(req, "election-ward.transfer-run", { moved, skipped: skipped.length });
      return reply(res, 200, { moved, skipped: skipped.length });
    }
    // 例月抹消処理（抹消予定者を一括抹消し、チェックリストを返す）
    if (pathname === "/api/election/roll/monthly-purge" && req.method === "POST") {
      requireRole(actor, "ADMIN");
      const voters = await readJson(votersFile, []);
      const checklist = [];
      for (const v of voters) {
        if (v.status === "抹消予定" || v.purgeReason) {
          v.status = "抹消";
          v.purgedAt = new Date().toISOString();
          checklist.push({ id: v.id, fullName: v.fullName, reason: v.purgeReason ?? "抹消予定" });
        }
      }
      await writeJson(votersFile, voters);
      await audit(req, "election-roll.monthly-purge", { count: checklist.length });
      return reply(res, 200, { count: checklist.length, checklist });
    }
    // 直接請求（地方自治法74・75・76・80・81・86条）: 署名簿審査日を基準日とした名簿
    if (pathname === "/api/election/roll/snapshot" && req.method === "GET") {
      requireRole(actor, "CLERK");
      const baseDate = searchParams.get("baseDate") ?? "";
      if (!baseDate) return reply(res, 400, { error: "baseDate required（署名簿審査日等の基準日）" });
      const voters = await readJson(votersFile, []);
      const list = voters.filter((v) => (v.registrationDate ?? "") <= baseDate && v.status !== "抹消");
      await audit(req, "election-roll.snapshot", { baseDate, count: list.length });
      return reply(res, 200, { baseDate, purpose: "直接請求（署名簿審査）基準日名簿", count: list.length, voters: list });
    }
    // 複数選挙の選挙ごと資格一括判定（同日選挙対応）
    if (pathname === "/api/election/eligibility/batch" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      if (!b.electionId || !b.electionDate) return reply(res, 400, { error: "electionId, electionDate required" });
      const voters = await readJson(votersFile, []);
      const results = voters.map((v) => ({
        voterId: v.id,
        ...computeElectoralEligibility({ birthDate: v.birthDate, addressFixedDate: v.addressFixedDate ?? v.registrationDate, asOf: b.electionDate }),
      }));
      const eligible = results.filter((r) => r.eligible).length;
      await audit(req, "election-eligibility.batch", { electionId: b.electionId, eligible, total: results.length });
      return reply(res, 200, { electionId: b.electionId, electionDate: b.electionDate, total: results.length, eligible, results });
    }

    // ════ 性別不記載/記号印字・索引簿・復権・期限抽出・一部選択処理・EUC出力・マニュアル（標準仕様書）════

    // ── 名簿・投票人名簿の出力（性別は記載しない。任意選択で判別記号の印字可）──
    if (pathname === "/api/election/roll/print" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      const voters = await readJson(votersFile, []);
      const genderMark = b.genderMark === true; // 任意選択
      const rows = voters.map((v) => ({ voterId: v.id, name: v.fullName ?? v.name ?? "",
        ...(genderMark ? { mark: (v.gender === "F" || v.gender === "女") ? "○" : "" } : {}) }));
      await audit(req, "election-roll.print", { genderMark, count: rows.length });
      return reply(res, 200, { kind: b.kind ?? "選挙人名簿", genderColumn: "記載しない",
        genderMark: genderMark ? "判別用記号を印字（任意選択）" : "なし", count: rows.length, rows });
    }

    // ── 投票区別カナ氏名50音順の索引簿 ──
    if (pathname === "/api/election/roll/index-book" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      const voters = Array.isArray(b.voters) && b.voters.length ? b.voters : await readJson(votersFile, []);
      const byWard = {};
      for (const v of voters) (byWard[v.precinct ?? v.ward ?? "第1投票区"] ??= []).push(v);
      const book = Object.entries(byWard).map(([precinct, list]) => ({
        precinct,
        entries: list.map((v) => ({ kana: v.kana ?? "", name: v.fullName ?? v.name ?? "" }))
          .sort((a, x) => String(a.kana).localeCompare(String(x.kana), "ja")),
      }));
      await audit(req, "election-roll.index-book", { precincts: book.length });
      return reply(res, 200, { document: "投票区別カナ氏名50音順索引簿", precincts: book.length, book });
    }

    // ── 失権者の復権処理 ──
    if (pathname === "/api/election/shikken/fukken" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      if (!b.voterId) return reply(res, 400, { error: "voterId required" });
      const voters = await readJson(votersFile, []);
      const idx = voters.findIndex((v) => v.id === String(b.voterId));
      if (idx === -1) return reply(res, 404, { error: "voter not found" });
      if (voters[idx].status !== "失権") {
        voters[idx]._fukkenNote = "失権状態でないため状態変更なし（参照実装は冪等扱い）";
      }
      voters[idx] = { ...voters[idx], status: "登録", fukken: { reason: b.reason ?? "失権事由消滅",
        at: new Date().toISOString(), by: actor.id } };
      await writeJson(votersFile, voters);
      await audit(req, "election-shikken.fukken", { voterId: b.voterId });
      return reply(res, 200, { voterId: String(b.voterId), status: "登録", restored: true });
    }

    // ── 任意の期間内に有効期限を迎える者の抽出（在外選挙人証・郵便等投票証明書）──
    if (pathname === "/api/election/certificates/expiring" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      const items = Array.isArray(b.items) ? b.items : [];
      if (!b.from || !b.to || !items.length) return reply(res, 400, { error: "from, to, items[]{certId,holder,expiresAt} required" });
      const hits = items.filter((i) => i.expiresAt >= b.from && i.expiresAt <= b.to);
      await audit(req, "election-certificates.expiring", { from: b.from, to: b.to, hits: hits.length });
      return reply(res, 200, { period: { from: b.from, to: b.to }, count: hits.length, list: hits });
    }

    // ── 訂正・取消の一部選挙人選択処理 ──
    if (pathname === "/api/election/corrections/batch" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      const actions = ["訂正", "取消"];
      const voterIds = Array.isArray(b.voterIds) ? b.voterIds : [];
      if (!actions.includes(b.action) || !voterIds.length) return reply(res, 400, { error: `action(${actions.join("/")}), voterIds[](選択した選挙人のみ) required` });
      await audit(req, "election-corrections.batch", { action: b.action, count: voterIds.length });
      return reply(res, 200, { action: b.action, processed: voterIds.map(String),
        note: "一部の選挙人のみを選択して訂正・取消処理" });
    }

    // ── EUC出力（複数項目ソート・四則演算項目・区分の和名変換・前開庁日条件）──
    if (pathname === "/api/election/euc/export" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = await parseBody(req);
      let rows = Array.isArray(b.rows) ? [...b.rows] : [];
      if (!rows.length) return reply(res, 400, { error: "rows[] required" });
      // 四則演算した項目（項目名指定）
      for (const calc of Array.isArray(b.computedFields) ? b.computedFields : []) {
        const ops = { "+": (x, y) => x + y, "-": (x, y) => x - y, "*": (x, y) => x * y, "/": (x, y) => (y ? x / y : 0) };
        const fn = ops[calc.op];
        if (!fn || !calc.name) continue;
        rows = rows.map((r) => ({ ...r, [calc.name]: fn(Number(r[calc.left] ?? 0), Number(r[calc.right] ?? 0)) }));
      }
      // システムパラメータ（区分）の職員判別可能な情報への変換
      for (const conv of Array.isArray(b.codeLabels) ? b.codeLabels : []) {
        rows = rows.map((r) => ({ ...r, [conv.field]: conv.map?.[String(r[conv.field])] ?? r[conv.field] }));
      }
      // 複数項目の昇順・降順ソート
      const sortBy = Array.isArray(b.sortBy) ? b.sortBy : [];
      rows.sort((a, x) => {
        for (const s of sortBy) {
          const va = a[s.field], vx = x[s.field];
          const cmp = typeof va === "number" && typeof vx === "number" ? va - vx : String(va ?? "").localeCompare(String(vx ?? ""), "ja");
          if (cmp !== 0) return s.dir === "desc" ? -cmp : cmp;
        }
        return 0;
      });
      // 前開庁日等の出力条件（日付指定不要の高頻度出力）
      let resolvedDate = null;
      if (b.dateCondition === "前開庁日") {
        const holidays = new Set(Array.isArray(b.holidays) ? b.holidays : []);
        const d = new Date((b.asOf ?? new Date().toISOString().slice(0, 10)) + "T00:00:00Z");
        do { d.setUTCDate(d.getUTCDate() - 1); }
        while ([0, 6].includes(d.getUTCDay()) || holidays.has(d.toISOString().slice(0, 10)));
        resolvedDate = d.toISOString().slice(0, 10);
      }
      await audit(req, "election-euc.export", { rows: rows.length, sorted: sortBy.length > 0 });
      return reply(res, 200, { count: rows.length, rows, resolvedDate,
        dateCondition: b.dateCondition ?? null });
    }

    // ── オンラインマニュアル ──
    if (pathname === "/api/election/manual" && req.method === "GET") {
      const MANUAL = {
        domain: { title: "名簿管理", body: "登録・抹消・失権/復権・移替えの操作手順。" },
        reports: { title: "帳票", body: "抄本・索引簿・入場券の出力手順。" },
        admin: { title: "管理", body: "選挙設定・投票受付・EUC出力の運用手順。" },
      };
      const screen = searchParams.get("screen") ?? "";
      if (screen) {
        const page = MANUAL[screen];
        if (!page) return reply(res, 404, { error: "unknown screen" });
        return reply(res, 200, { screen, ...page, kind: "オンラインマニュアル（画面別ヘルプ）" });
      }
      return reply(res, 200, { kind: "オンラインマニュアル（目次）",
        chapters: Object.entries(MANUAL).map(([k, v]) => ({ screen: k, title: v.title })) });
    }

    // ── 補正登録候補者の自動判定・抽出（標準仕様書 0060015/0060066/0060210）──
    //   同一市区町村内で住所異動により投票区が変わった登録者（転出・死亡・国籍喪失等は除く）を補正登録候補として自動抽出する。
    if (pathname === "/api/electoral/hosei-touroku/judge" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = (await parseBody(req)) || {};
      const voters = Array.isArray(b.voters) ? b.voters : [];
      const 候補 = voters.filter((v) => {
        if (v.転出 === true || v.死亡 === true || v.国籍喪失 === true || v.抹消 === true) return false; // 抹消事由は補正対象外
        const same = v.同一市区町村 !== false; // 既定で同一市区町村内
        const moved = String(v.登録時投票区 ?? "") !== String(v.現在投票区 ?? "") && v.現在投票区 != null && v.登録時投票区 != null;
        return same && moved; // 同一市区町村内で投票区が変わった者
      }).map((v) => ({ voterId: v.voterId ?? null, 氏名: v.氏名 ?? null,
        登録時投票区: v.登録時投票区, 現在投票区: v.現在投票区, 事由: "住所異動による投票区変更" }));
      await audit(req, "electoral.hosei-touroku.judge", { 対象: voters.length, 候補: 候補.length });
      return reply(res, 200, { 判定対象: voters.length, 補正登録候補数: 候補.length, 候補,
        note: "同一市区町村内で住所異動により投票区が変わった登録者を補正登録候補として自動判定・抽出（抹消事由者は除外）。" });
    }
    // ── 選挙ごとの投票可否の自動判定（標準仕様書 0090052）──
    //   選挙の期日・選挙区に対し、選挙人ごとに投票可否を判定。不可の場合は不可の選挙とその理由を返す。
    if (pathname === "/api/electoral/voting-eligibility" && req.method === "POST") {
      requireRole(actor, "CLERK");
      const b = (await parseBody(req)) || {};
      const v = b.voter || {};
      const elections = Array.isArray(b.elections) ? b.elections : [];
      const ageAt = (birth, asOf) => { const m1 = String(birth ?? "").match(/^(\d{4})-(\d{1,2})-(\d{1,2})/),
        m2 = String(asOf ?? "").match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); if (!m1 || !m2) return null;
        let a = Number(m2[1]) - Number(m1[1]); if (Number(m2[2]) < Number(m1[2]) || (Number(m2[2]) === Number(m1[2]) && Number(m2[3]) < Number(m1[3]))) a -= 1; return a; };
      const results = elections.map((el) => {
        const reasons = [];
        if (v.登録 === false || v.抹消 === true) reasons.push("選挙人名簿に登録されていない");
        const age = ageAt(v.生年月日, el.期日);
        if (age != null && age < 18) reasons.push("選挙期日に満18歳未満");
        if (v.表示登録 === true && el.種別 !== "国政") reasons.push("表示登録者は当該選挙の投票区外（国政選挙以外）");
        if (el.選挙区 != null && v.選挙区 != null && String(el.選挙区) !== String(v.選挙区)) reasons.push("当該選挙の選挙区に属さない");
        if (v.欠格 === true) reasons.push("選挙権の欠格事由に該当");
        if (el.引き続き居住要件 === true && v.引き続き居住 === false) reasons.push("引き続き居住要件を満たさない");
        return { 選挙: el.名称 ?? el.種別 ?? null, 期日: el.期日 ?? null, 投票可否: reasons.length === 0 ? "可" : "不可", 理由: reasons };
      });
      const 不可 = results.filter((r) => r.投票可否 === "不可");
      await audit(req, "electoral.voting-eligibility", { 選挙数: results.length, 不可数: 不可.length });
      return reply(res, 200, { results, 投票不可の選挙: 不可,
        note: "選挙ごとに投票可否を自動判定し、不可の場合はその選挙と理由(未登録/18歳未満/選挙区外/欠格/居住要件等)を返す。" });
    }

  return false;
}
