/**
 * レポート系メソッド（reportKpis / facilityPerformance / errorBreakdown / continuousProblems）を
 * 組み立てるときの変換ロジック（純粋関数のみ）。
 *
 * ★`lib/db/clean-log-mapping.ts` と同じ作法★ — DBアクセスを混ぜず、`prismaSource` の
 * 各メソッドから呼ぶだけにして、テストは fixtures 相当の入力と純粋関数だけで書けるようにする
 * （指示書T 2-4 / CLAUDE.md 禁止事項13）。
 *
 * ★`continuousProblems` は指示書Wで実装した（第6歩当時は対応するサマリテーブルが無く先送り
 *   していたが、詳細テーブルから算出する形で実現した。D-102項4）★。
 */
// ★完了率は1回ごとの完了率（`runCompletionRate()`）の単純平均（`meanCleanRatio()`）★（D-155）。
//   ★`meanCleanRatio()` は `lib/judge/kpi.ts` に移した★（2026-09-30。`CLAUDE.md` §9.1:
//   KPI計算は `lib/judge/`。★レポート画面・F-08・エリア別完了率・R-3 が同じ関数で畳む★。§5-1）。
//   ★面積比の `completionRate()` は使う箇所が0件になったので削除された★。
import {
  errorRate,
  isCompletedTask,
  judgeEscalationLevel,
  launchRate,
  meanCleanRatio,
  operationRate,
  runCompletionRate,
  toKpiValue,
} from "@/lib/judge";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { fromDbJst, jstIsoFromDb } from "@/lib/format/date";
import { isResultPending } from "@/lib/clean-logs/result-pending";
import { weekOfMonth } from "@/lib/format/period";
import type {
  ContinuousProblem,
  ErrorBreakdownItem,
  Period,
  ReportKpi,
  ReportPeriodKey,
} from "@/lib/types";
// ★`notExecutedFromHistory()` / `CleanResultLike` はもうこのファイルからは使わない★ —
//   `clean_task_reports` には `clean_error` 相当の列が無く（指示書AQ 1-1）、
//   ★未実施の判定は `aggregateCleanStatsByFacility()` の引数（述語）で受け取る★形にしたため。
//   ★新しい判定関数を作るのは担当C★（`lib/db/clean-log-mapping.ts`）。
import {
  foldProblemDaysByDate,
  isFutureSchedule,
  type ConsecutiveInput,
} from "./clean-log-mapping";
// ★`detectedAtFromError()` はもうこのファイルからは使わない★（期間帰属を本清掃の `start_at` に
//   変えたため。決定7）。★関数自体は削除しない — `prechecks()`（アラート）が使っている★
import type { PrecheckDetectedAtSource } from "./precheck-mapping";

// ---------------------------------------------------------------- 連続問題施設（summary テンプレート）

/**
 * `ContinuousProblem.summary` に返す3種のテンプレート文字列（指示書W 確定1・確定2・確定3）。
 *
 * ★数値を埋めずにテンプレートのまま返す（方式A）★ — 画面側が `{n}` に
 *   `consecutive_problem_days`、`{m}` に `launch_failed_days` を差し込む。
 * ★★`lib/i18n.ts` に足すキーと1文字も違ってはいけない★★（全角括弧・「うち」・「は」を含む）。
 *   違うと `translate()` が未登録キーとして日本語のまま返す（fail-silent。D-111 項8）。
 * ★対処の助言も原因の推定も含まない（確定1。D-111）★。
 */
export const SUMMARY_TEMPLATE_COMPLETION = "{n}日連続で完了率が△以下";
export const SUMMARY_TEMPLATE_LAUNCH_FAILED = "{n}日連続で出発失敗";
export const SUMMARY_TEMPLATE_MIXED = "{n}日連続で△以下（うち{m}日は出発失敗）";

/**
 * 連続日数が★表示上限で頭打ちになった★ときのテンプレート3種（指示書AF 段階5-1）。
 *
 * ★数値を埋めずにテンプレートのまま返す（方式A）★ — 上の3本と同じ。
 * ★★`lib/i18n.ts` に足すキーと1文字も違ってはいけない★★（全角括弧を含む）。
 *   違うと `translate()` が未登録キーとして日本語のまま返す（fail-silent。D-111 項8）。
 * ★対処の助言も原因の推定も含まない（確定1。D-111）★。
 */
export const SUMMARY_TEMPLATE_CAPPED_COMPLETION = "{n}日以上連続で完了率が△以下";
export const SUMMARY_TEMPLATE_CAPPED_LAUNCH_FAILED = "{n}日以上連続で出発失敗";
/** ★★`{m}` を持たない★★ — 上限で切った窓の中の内訳は計算できないため（指示書AF §B-5） */
export const SUMMARY_TEMPLATE_CAPPED_MIXED = "{n}日以上連続で△以下（出発失敗を含む）";

// ---------------------------------------------------------------- 期間キー

/**
 * サマリ4テーブルの行（`month` / `week`）から `Period` を決める。
 *
 * | 条件 | 戻り |
 * |---|---|
 * | ★`month === 0 && week === 0`★ | ★`"yearly"`★ |
 * | `week === 0`（`month >= 1`） | `"monthly"` |
 * | それ以外 | `"weekly"` |
 *
 * ★★`month` を必ず見ること★★（`CLAUDE.md` §4.7 / 指示書AQ §3 罠2）—
 *   ★以前は `week === 0 ? "monthly" : "weekly"` で `month` を見ていなかった★ため、
 *   ★年次行（`month=0 && week=0`）が「月次」として静かに混入★した。
 *   呼び出し側の「年次除外 filter」が外れた瞬間、`customerSummariesByPeriodKey()` が
 *   ★`monthly/2026/0/0` の重複を検知して 500★ になる（＝気づけるのはそこまで行った時だけで、
 *   `errorBreakdownRowsFromSummary()` 側は★年次の件数を月次に足し込んだまま黙って通る★）。
 *
 * ★★引数は「行そのもの」を受ける（`(month, week)` の2引数にしない）★★ —
 *   `month` と `week` はどちらも `number` なので、★位置を取り違えても型エラーにならない★。
 *   サマリ行はいずれも `month` / `week` を持つので、呼び出し側は `row` をそのまま渡せる。
 *
 * ★`"yearly"` は `Period`（`lib/types/index.ts`）への追加が前提★ — 追加は★担当B★の担当。
 */
export function periodOfSummaryRow(row: { month: number; week: number }): Period {
  if (row.month === 0 && row.week === 0) return "yearly";
  if (row.week === 0) return "monthly";
  return "weekly";
}

/** `${period}/${year}/${month}/${week}` の形の Map キー（`reportKpis()` の前期引き用） */
export function periodMapKey(
  period: Period,
  year: number,
  month: number,
  week: number
): string {
  return `${period}/${year}/${month}/${week}`;
}

/**
 * `customer_summaries` 1行ぶんの最小限の形（`customerSummariesByPeriodKey()` の入力）。
 *
 * ★`customer_id` を `bigint | number` の両方で受ける★ — Prisma は `bigint` を返すが、
 *   スタブや検証スクリプトが `number` を渡しても ★`1n` と `1` が別の顧客として扱われない★
 *   ようにするため（判定は `String()` で正規化する。落とし穴: `1n !== 1`）。
 */
export interface CustomerSummaryOwnerLike {
  customer_id: bigint | number;
  year: number;
  month: number;
  week: number;
}

/**
 * `customer_summaries` の行を ★期間キー → 行★ の Map にする（`reportKpis()` の確定期間用）。
 *
 * ★★複数の顧客の行が混ざっていたら例外にする（D-5。ユーザー判断 2026-09-14）★★ —
 *   「★レポートのKPIは運用担当が判断に使う数字。別会社の数字で判断されるより、
 *   画面が出ないほうがまし★」。
 *
 * ★★検知を「期間キーの衝突」だけで書かないこと★★（★指示書AH 罠5★）—
 *   壊れ方は2系統あり、別々に選ばれる:
 *
 *   | | 何が起きるか |
 *   |---|---|
 *   | 前期比 | 期間キー（`customer_id` を含まない）の Map で★後勝ち上書き★ |
 *   | 当期の値 | `selectKpiRow()` の `rows.find()` が★最初の1行★を採る |
 *
 *   ★A社が当期の行だけ・B社が前期の行だけを持つとキーは衝突しない★が、
 *   ★前期比だけが静かに汚染される★（差分が両社の差という無意味な数字になる）。
 *   だから ★判定は「全行の `customer_id` が1種類か」★ で行い、キー衝突は別途併せて見る。
 *
 * ★★同一顧客で同じ期間キーが2行来た場合も例外にする★★（起草者の判断。ユーザー決定の拡張）—
 *   スキーマに一意制約が無いので起こりえて、★壊れ方（後勝ちで不定）が同じ★だから。
 *
 * ------------------------------------------------------------------------
 * ★★2026-09-16（指示書AK ★段階6★）— `where` を `customer_id` で絞るようになった★★
 * ------------------------------------------------------------------------
 * ★以前ここには「★`customer_id` で `where` を絞って回避しないこと★」と書いてあった★ —
 *   ★分類（ホールディングス／屋号）が画面から消えていた当時（D-053）は
 *   ★★どの顧客かを決める根拠が無かった★★ため。★その前提は変わった★:
 *   `/reports/kpi` は `group_id` / `customer_id` を実際に読み、
 *   ★`resolveTargetCustomer()`（`lib/reports/target-customer.ts`）が対象顧客を1社に決める★。
 *   ★決まらなければデータ層を呼ばずに 422★（指示書AK §2-1）。
 *   ★★だから「静かに1社を選ぶ」ことは今もしていない★★（D-133 の否定は生きている）。
 *
 * ★★それでもこの関数は残す★★（★指示書AK §9-2 の指定★）:
 *   - ★同じ期間キーの重複は同一顧客でも起こりうる★（`customer_summaries` に一意制約が無い。
 *     ★後勝ちで不定になる★。期待値表 6-8）
 *   - ★複数顧客の検知も防御的に残す★ — `where` の書き換えで絞りが外れたときに、
 *     ★静かに別会社の数字を混ぜるより止まるほうがまし★（D-5 のユーザー判断）
 * ★「期待する `customer_id`」を `lib/config/` に置くのは今も不可★（ユーザーが採らなかった案B）。
 *
 * ★★年次（`month === 0 && week === 0`）の扱い★★（★指示書AQ §3 罠2★）—
 *   ★`periodOfSummaryRow()` が `month` を見るようになった★ので、年次行が混ざっても
 *   期間キーは `yearly/2026/0/0` になり、★月次（`monthly/2026/9/0`）とは衝突しない★。
 *   ★以前ここには「年次を除外した**後**に呼ぶこと」と書いてあった★ — それは
 *   ★`periodOfSummaryRow()` が年次を「月次」と誤判定していた当時の回避策★であり、
 *   ★根本原因が直った今は前提条件ではない★（呼び出し側で年次を読むかどうかは
 *   `lib/db/index.ts` 側の判断。★担当G★）。
 *   ★ただし「同じ期間キーが2行」の検知はそのまま残る★ — 年次行が本当に2行あれば止まる。
 *
 * ★施設サマリ（`facility_summaries`）には広げない★（D-6）— あちらは Map キーに
 *   `facility_id` が入っているので★上書きが起きない★（壊れ方が違う）。
 *
 * ★例外メッセージには `customer_id` と期間キーだけを載せる★ — 施設名などの PII を入れない
 *   （ルート層は固定文言の500に潰すのでレスポンスには出ないが、ログには出るため）。
 */
export function customerSummariesByPeriodKey<T extends CustomerSummaryOwnerLike>(
  rows: readonly T[]
): Map<string, T> {
  // ★まず「全行の customer_id が1種類か」を見る（キー衝突より先。罠5）★
  const customerIds = new Set<string>();
  for (const row of rows) customerIds.add(String(row.customer_id));
  if (customerIds.size > 1) {
    throw new Error(
      `[report-mapping] customerSummariesByPeriodKey: customer_summaries に複数の顧客の行が混ざっています（customer_id=${[
        ...customerIds,
      ]
        .sort()
        .join(", ")}）。どの顧客のKPIかを決める根拠が無いため中断します（D-5 / D-053）`
    );
  }

  const byPeriodKey = new Map<string, T>();
  for (const row of rows) {
    const key = periodMapKey(periodOfSummaryRow(row), row.year, row.month, row.week);
    if (byPeriodKey.has(key)) {
      throw new Error(
        `[report-mapping] customerSummariesByPeriodKey: 同じ期間キー（${key}）の customer_summaries が複数あります（customer_id=${String(
          row.customer_id
        )}）。一意制約が無く後勝ちで不定になるため中断します（D-5）`
      );
    }
    byPeriodKey.set(key, row);
  }
  return byPeriodKey;
}

// ---------------------------------------------------------------- 確定期間（サマリテーブル由来）

/** サマリ行が持つ4本の率フィールド（customer_summaries / facility_summaries 共通） */
export interface SummaryRatios {
  operation_ratio: number;
  launch_ratio: number;
  clean_ratio: number;
  error_ratio: number;
}

/**
 * サマリ行の率 + 前期行の率 → `ReportKpi`。
 * ★サマリの値はそのまま `KpiValue.value` に入れてよい（D-094。率はすでに `double(4,1)`）★
 * ★履歴（詳細テーブル）から再計算しない（D-102 / CLAUDE.md 禁止事項1）★
 */
export function kpiFromSummaryRatios(
  current: SummaryRatios,
  prev: SummaryRatios | undefined
): ReportKpi {
  return {
    operation_rate: toKpiValue(current.operation_ratio, prev?.operation_ratio ?? null),
    launch_rate: toKpiValue(current.launch_ratio, prev?.launch_ratio ?? null),
    completion_rate: toKpiValue(current.clean_ratio, prev?.clean_ratio ?? null),
    error_rate: toKpiValue(current.error_ratio, prev?.error_ratio ?? null),
  };
}

// ---------------------------------------------------------------- 当期（詳細テーブルからその場集計）

/**
 * 当期のKPIを組み立てるための中間集計値（施設1件、または全社ぶんの合算）。
 *
 * ★★当期の3つの率はサマリと同じ式★★（第11回 P-6。D-158）。★どれも分母は `planned`★:
 *   稼働率 ＝ `completed` ÷ `planned` / 出発成功率 ＝ `executed` ÷ `planned` /
 *   エラー発生率 ＝ `errorCount` ÷ `planned`（上限で切らない）。完了率だけは `cleanRatioSum` ÷ `cleanRatioCount`。
 *
 * - `planned` ＝ 期間内・過去の本清掃の予定の数
 * - `executed` ＝ そのうちレポート行がある数（★開始できた回数＝出発成功率の分子★）
 * - ★`completed` ＝ そのうち最新のレポート行の `status` が正常終了の数（稼働率の分子。`isCompletedTask()`）★
 * - `errorCount` ＝ エラーの数（エラー発生率の分子）
 *
 * ★時刻の条件は無い★（P-6=A）。`clean_task_reports.started_at` は、予定のある清掃では予定の `start_at` と
 *   同じ値、予定外のタスクでは実際の開始時刻が入る（ユーザー回答 2026-10-05。`CLAUDE.md` §4.7）ので、
 *   ★定刻かどうかはそもそも区別できない★。★出発成功率は時刻を見ない（P-6=A）★。
 *   旧: 「`clean_timetables.started_at` は常に予定時刻と同値」と書き、稼働率と出発成功率に同じ `executed` を
 *   使っていた（その列は存在せず、記述が誤りだった。D-157）。
 *
 * ★不変条件: `completed ≤ executed ≤ planned`★（だから 稼働率 ≤ 出発成功率 ≤ 100）。
 * 確定期間（`kpiFromSummaryRatios`）はサマリの値をそのまま読む（D-158。ここは当期だけ）。
 */
export interface CleanStatsBucket {
  /** 清掃予定回数 */
  planned: number;
  /** 開始できた回数（レポート行がある回数）。出発成功率の分子 */
  executed: number;
  /** ★正常終了の回数（最新のレポート行の `status` が正常終了。`isCompletedTask()`）。稼働率の分子（P-6=A）★ */
  completed: number;
  /** 清掃対象面積の合計（㎡） */
  taskArea: number;
  /** 清掃面積の合計（㎡） */
  cleanArea: number;
  /**
   * エラー発生回数。
   *
   * ★★本清掃行（`precheck === CLEAN_TIMETABLE_PRECHECK.main`）を指す
   *   `robot_error_reports` の件数★★（TeamWorks 第8回 A7。2026-09-11。D-114 項2）—
   *   ★事前チェック行を指すエラーは含めない★（「事前チェックは本清掃を円滑に実施できる
   *   ためのものなので, サマリには含めない」という回答）。
   *   ★`clean_timetable_id` が `null` の行 / dangling の行も含めない★（決定3）。
   *
   * ★★以前ここには「事前チェック行に紐づく」と書いてあった★★ — ★第8回で質問する前の
   *   **仮定**であり、回答で逆だと分かった。★古い記述を残さない★（次に読む人が両方を信じるため）。
   *
   * ★分母は予定回数（第11回 P-6=B）★。
   *
   * ★清掃の結果は `clean_task_reports` だけから読み、`clean_error` 列は読まない★（D-151）。
   *   ★制御盤・セキュリティのエラー、清掃を開始できなかったエラーも `robot_error_reports` に入り★
   *   （第11回 P-8・P-10）、★`clean_timetable_id` も入る★（P-9=A）。
   *   ★本清掃の予定の id が入った行は、区分を問わずこの件数に入る★（どの予定の id が入るかは無回答）。
   *   ★区分（ロボット／制御盤／セキュリティ・清掃開始失敗）の見分けと D-Security アラートの取得元の
   *   切り替えは次フェーズ★（D-158 当方の対応5）。
   *   旧: 第8回 A6-1「`clean_error` 2/3 の清掃は行が作られない」— 第11回で変わった。
   */
  errorCount: number;
  /**
   * ★1回ごとの完了率（`runCompletionRate()` の戻り）の**合計**★（完了率の分子。D-155）。
   * ★名前は変えていない★（指示書AS D7）— 中身は `clean_task_reports.clean_ratio` 列ではない。
   *
   * ★★平均そのものを持たない理由★★ — ★率は加重平均できない★（`CLAUDE.md` §4.7 の
   *   「率は加重平均できないため合算できない」と同じ話）。★Σ と件数を別々に持てば、
   *   施設をまたいで合算しても平均が壊れない★（`sumCleanStatsBuckets()`）。
   *   平均を取り出すのは ★`meanCleanRatio()` だけ★。
   */
  cleanRatioSum: number;
  /**
   * 上の `cleanRatioSum` に足した件数（＝完了率の平均の★分母★）。
   * ★いまは `executed` と同じ値になる★（`task_area = 0` も 0% として数えるため。P-5=B。D-158）。
   * ★それでも別に持つ★（Σ と件数の組で `meanCleanRatio()` に渡す形を変えないため）。
   */
  cleanRatioCount: number;
}

export function emptyCleanStatsBucket(): CleanStatsBucket {
  return {
    planned: 0,
    executed: 0,
    completed: 0,
    taskArea: 0,
    cleanArea: 0,
    errorCount: 0,
    cleanRatioSum: 0,
    cleanRatioCount: 0,
  };
}

function addCleanStats(a: CleanStatsBucket, b: CleanStatsBucket): CleanStatsBucket {
  return {
    planned: a.planned + b.planned,
    executed: a.executed + b.executed,
    completed: a.completed + b.completed,
    taskArea: a.taskArea + b.taskArea,
    cleanArea: a.cleanArea + b.cleanArea,
    errorCount: a.errorCount + b.errorCount,
    cleanRatioSum: a.cleanRatioSum + b.cleanRatioSum,
    cleanRatioCount: a.cleanRatioCount + b.cleanRatioCount,
  };
}

/*
 * ★`meanCleanRatio()`（清掃完了率の畳み方）はここから `lib/judge/kpi.ts` に移した★（2026-09-30）—
 *   ★挙動は同じ★（単純平均・件数0は `null`）。根拠の docstring も一緒に移してある。
 *   ★`CleanStatsBucket` は `CleanRatioTally`（`cleanRatioSum` / `cleanRatioCount`）を満たすので、
 *   そのまま渡せる★。★ここで再定義しないこと★（畳み方が2箇所に分かれる）。
 */

/** 複数施設ぶんのバケットを1つに合算する（`reportKpis()` の全社集計用） */
export function sumCleanStatsBuckets(
  buckets: Iterable<CleanStatsBucket>
): CleanStatsBucket {
  let total = emptyCleanStatsBucket();
  for (const bucket of buckets) total = addCleanStats(total, bucket);
  return total;
}

/** `clean_timetables`（本清掃のみ）1件ぶんの最小限の形 */
export interface MainTimetableLike {
  id: bigint;
  facility_id: bigint;
  start_at: Date;
}

/**
 * ★当期の率の集計（`aggregateCleanStatsByFacility()`）が読む本清掃の形★ — `MainTimetableLike` ＋ ★`end_at`★。
 * `end_at` は ★結果待ち（`isResultPending()`）の判定用★（表2 C11。ユーザー判断 2026-10-07。D-173）。
 * ★`start_at` と同じ JST の壁時計値★（`fromDbJst()` を通す）。
 * ★`MainTimetableLike` に足さなかった理由★: エラー件数の集計（`errorCounts` 系）は終了時刻を使わないので、
 *   そちらの読み取りに列を増やさないため。
 */
export interface CleanStatsTimetableLike extends MainTimetableLike {
  end_at: Date;
}

/**
 * 本清掃行（`precheck === CLEAN_TIMETABLE_PRECHECK.main`）の id（文字列） → `start_at`。
 *
 * ★2つの用途を1本で賄う★ —
 *   1. ★キーの集合★が「本清掃行の id の Set」になる（エラーの分子の判定に使う）
 *   2. ★値★が期間帰属の基準になる（決定7。エラーの期間は本清掃の `start_at` の JST 日付）
 *
 * ★`BigInt` は `===` でキー比較できないので文字列化する★。
 */
export function mainTimetableStartAtById(
  mainRows: readonly MainTimetableLike[]
): Map<string, Date> {
  const map = new Map<string, Date>();
  for (const row of mainRows) {
    map.set(row.id.toString(), row.start_at);
  }
  return map;
}

/**
 * `robot_error_reports` を ★本清掃id単位★ の件数に集計する（★エラー率の分子★）。
 *
 * ★★分子の定義（TeamWorks 第8回 A7。2026-09-11。D-114 項2）★★:
 *   ★本清掃行を指すエラーだけを数える★。★事前チェック行を指すものは含めない★
 *   （「事前チェックは本清掃を円滑に実施できるためのものなので, サマリには含めない」）。
 *
 * ★★「本清掃行の集合に**入っている**」という肯定条件で数える★★ —
 *   `clean_timetable_id` には FK も KEY も無く（`prisma/schema.prisma`。
 *   `lib/db/index.ts` の落とし穴1）、★`null` も dangling もありうる★ので
 *   「事前チェックでない＝本清掃」と推測してはいけない。
 *   ★`null` と dangling はこの条件の自然な帰結として落ちる★（決定3。特別扱いのコードを足さない）。
 */
export function errorCountByMainTimetable(
  errorReports: readonly { clean_timetable_id: bigint | null }[],
  mainStartAtById: ReadonlyMap<string, Date>
): Map<string, number> {
  const result = new Map<string, number>();
  for (const report of errorReports) {
    if (report.clean_timetable_id === null) continue;
    const key = report.clean_timetable_id.toString();
    if (!mainStartAtById.has(key)) continue;
    result.set(key, (result.get(key) ?? 0) + 1);
  }
  return result;
}

/**
 * `clean_task_reports` 1行ぶんの、この集計に必要な最小限の形（指示書AQ 決定2）。
 *
 * ★`clean_error` は持たない★ — `clean_task_reports` に ★`clean_error` 相当の列が無い★
 *   （指示書AQ 1-1）。★未実施の判定はこの型からは出せない★ので、
 *   `aggregateCleanStatsByFacility()` は判定を★引数（`isNotExecuted`）で受け取る★。
 * ★`clean_area` / `task_area` は1回ごとの完了率（`runCompletionRate()`）の材料★（D-155）。
 *   面積の合計（`taskArea` / `cleanArea`）にも足す。
 * ★`clean_ratio` は完了率に使っていない★（D-155）。★`runCompletionRate()` を列に戻すときのために
 *   残している★（指示書AS D6）。
 *
 * ★★`lib/db/clean-log-mapping.ts` の `CleanTaskReportLike`（★担当C★）とは別の型★★ —
 *   いまは列が同じだが、★用途が違う★（あちらは清掃詳細ログ1行の完了率、こちらは期間集計）ので、
 *   ★名前を分けてある★
 *   （同名の型が2つあると、`lib/db/index.ts` が両方を import したときに
 *   ★どちらの意味か読めなくなる★）。★TypeScript は構造的部分型なので、
 *   面積列を含む行はそのまま両方の引数に渡せる★。
 */
export interface CleanStatsReportLike {
  clean_ratio: number;
  task_area: number;
  clean_area: number;
  /**
   * `clean_task_reports.status`。★稼働率の分子（`isCompletedTask()`）にだけ使う★（第11回 P-6=A）。
   * ◎○△× の判定・未実施の判定には使わない（D-155 項2）
   */
  status: number;
}

/**
 * 本清掃行 + 清掃結果（`clean_task_reports`）+ エラー件数（本清掃id単位）から、
 * 期間内・施設ごとの `CleanStatsBucket` を作る。
 *
 * ★未来の予定（`isFutureSchedule()`）は分母（`planned`）にも分子にも加算しない★
 *   （指示書V 不具合A）— `clean_timetables` には1週間分の予定がまとめて登録される（D-106）ので、
 *   まだ来ていない予定を分母に入れると稼働率・出発成功率・エラー発生率が実際より下振れする。
 *   `cleanLogs()`（`lib/db/index.ts`）・`prechecks()`・連続日数の算出と同じ扱いに揃えた。
 * ★★結果待ち（`isResultPending()`）の予定も、未来の予定と同じく数えない★★（予定回数にも入れない。
 *   表2 C11。ユーザー判断 2026-10-07。D-173）— レポートがまだ届いていないだけかもしれない清掃を
 *   未実施として分母・分子に入れると、当期の率が届くまでのあいだだけ下振れする。
 *   ★バケットは作る（決定1。行ごと捨てない）★。★判定に使うレポートの有無は `isNotExecuted` と同じ述語★。
 *
 * ★★ただし「行ごと捨てない」★★（決定1。2026-09-11 ユーザー確定）— この Map のキーが
 *   `facilityPerformance()` の当期行そのものになる（`lib/db/index.ts` の当期組み立て箇所）ため、
 *   未来の行しか無い施設のバケットを作らずに捨てると、その施設が一覧から消える
 *   （週の初め・月の初めに全施設が消える不具合として実測された）。
 *   ★範囲外の行だけ `continue` で捨て、未来の行は空のバケットだけ作って加算をスキップする★。
 *
 * ★★完了率は1回ごとの完了率（`runCompletionRate()`）の単純平均★★（D-155。期間の合計どうしの
 *   面積比は D-151 で廃止したまま）— 畳み方は ★「清掃1回 = 1票」★（根拠は `meanCleanRatio()` の docstring）。
 *   ここでは ★Σ（`cleanRatioSum`）と件数（`cleanRatioCount`）を積むだけ★で、割らない。
 *   ★1回ごとの完了率を足すのは「実施と数えた行 かつ 結果行が存在する」ときだけ★
 *   （`task_area = 0` の清掃は 0% として足す。P-5=B。D-158。★いまは件数が `executed` と同じ値になる★）—
 *   未実施を 0% として混ぜると、★稼働率で既に数えている失敗を完了率でも二重に数える★
 *   （旧実装の面積比でも、未実施行は `task_area` / `clean_area` に何も足さないので
 *   分母にも分子にも入っていなかった。★その扱いを引き継ぐ★）。
 * ★`clean_area` / `task_area` の合算は ★結果行があれば未実施判定と無関係に足す★ —
 *   総清掃面積（F-08。指示書AQ 1-3）は「実際に清掃した面積」なので、
 *   完了率の都合で落とさない（これも旧実装と同じ挙動）。
 *
 * @param mainRows `clean_timetables`（precheck=本清掃のみ）の全件
 * @param reportByTimetableId 本清掃id（文字列）→ `clean_task_reports` の1行。
 *   ★どの1行を採るか（`ended_at` ?? `started_at` が最新）は呼び出し側の責務★（指示書AQ 1-4。★担当C★）
 * @param isNotExecuted ★「未実施（＝出発失敗）か」を判定する述語★。
 *   ★★このファイルに規則を書かない★★ — 指示書AQ 1-1 の
 *   「★未実施 ＝ 対応する `clean_task_reports` の行が無い★」は ★担当C★ が
 *   `lib/db/clean-log-mapping.ts` に置く。ここは★受け取って呼ぶだけ★にして、
 *   ★規則が2箇所に散らない★ようにする（D-103 項1と同じ理由）。
 *   ★引数にした（TODO コメントにしなかった）理由★: 名前で参照すると担当Cの完成待ちになり、
 *   その間このファイルのテストが書けない。★述語を注入すれば依存が切れる★。
 * @param errorCountByMain `errorCountByMainTimetable()` の戻り値（本清掃idキー）
 * @param range `periodRange()` の戻り値（JST日付の [from, to]、両端含む）
 * @param now 「まだ来ていない予定」を判定する基準時刻。★必須引数（既定値を置かない）★ —
 *   省略できると「今日の日付」でテストが通ってしまい、境界値の検証にならないため
 */
export function aggregateCleanStatsByFacility(
  mainRows: readonly CleanStatsTimetableLike[],
  reportByTimetableId: ReadonlyMap<string, CleanStatsReportLike>,
  isNotExecuted: (report: CleanStatsReportLike | undefined) => boolean,
  errorCountByMain: ReadonlyMap<string, number>,
  range: { from: string; to: string },
  now: Date
): Map<number, CleanStatsBucket> {
  const result = new Map<number, CleanStatsBucket>();
  for (const row of mainRows) {
    // ★DBのDATETIMEには日本時間の壁時計値が入っている（TeamWorks第8回A2。指示書Y）ので、
    //   `jstIsoFromDb()` でJST日付の桁を作ってから比較する★
    //   （YYYY-MM-DDは辞書順=日付順。`lib/format/period.ts` の isCurrentPeriod() と同じ考え方）
    const jstDate = jstIsoFromDb(row.start_at).slice(0, 10);
    if (jstDate < range.from || jstDate > range.to) continue;

    const facilityId = Number(row.facility_id);
    const bucket = result.get(facilityId) ?? emptyCleanStatsBucket();

    // ★期間内にはあるが、まだ来ていない予定はバケットを作るだけで加算しない（決定1）★
    if (isFutureSchedule(fromDbJst(row.start_at), now)) {
      result.set(facilityId, bucket);
      continue;
    }

    const key = row.id.toString();
    const report = reportByTimetableId.get(key);
    const executed = !isNotExecuted(report);
    // ★結果待ちも未来の予定と同じく、バケットを作るだけで加算しない（表2 C11。D-173）★
    if (
      isResultPending({
        startAt: fromDbJst(row.start_at),
        endAt: fromDbJst(row.end_at),
        now,
        hasReport: executed,
      })
    ) {
      result.set(facilityId, bucket);
      continue;
    }
    // ★正常終了（稼働率の分子。P-6=A）は `isCompletedTask()` だけが決める★（status だけで決める。面積は見ない）
    const completed = executed && isCompletedTask(report);
    // ★完了率の分子・分母に入れるのは「実施と数えた行 かつ 結果行がある」ときだけ★
    //   （結果行が無ければ足す完了率が無い。0 を足すと「0%で清掃した」に化ける）。
    //   ★1回ごとの完了率は `runCompletionRate()` だけが決める★（D-155。行をそのまま渡す。D6）。
    const runRate = executed && report !== undefined ? runCompletionRate(report) : null;

    result.set(facilityId, {
      planned: bucket.planned + 1,
      executed: bucket.executed + (executed ? 1 : 0),
      completed: bucket.completed + (completed ? 1 : 0),
      taskArea: bucket.taskArea + (report?.task_area ?? 0),
      cleanArea: bucket.cleanArea + (report?.clean_area ?? 0),
      errorCount: bucket.errorCount + (errorCountByMain.get(key) ?? 0),
      cleanRatioSum: bucket.cleanRatioSum + (runRate ?? 0),
      cleanRatioCount: bucket.cleanRatioCount + (runRate === null ? 0 : 1),
    });
  }
  return result;
}

/**
 * `CleanStatsBucket` + 前期の4率 → `ReportKpi`（当期用）。
 *
 * ★稼働率・出発成功率・エラー発生率は `lib/judge/kpi.ts` の3関数に通す★（`CLAUDE.md` §4.2）。
 *   ★3つとも分母は予定回数（第11回 P-6。サマリと同じ）★。旧: エラー発生率の分母は実施回数だった。
 * ★★完了率だけは1回ごとの完了率の単純平均（`meanCleanRatio()`。`lib/judge/kpi.ts`）★★（D-155）—
 *   ★期間の合計どうしの面積比は使わない★。理由は `meanCleanRatio()` の docstring。
 * ★4指標とも丸めない生値を `KpiValue.value` に入れる（`CLAUDE.md` §4.3）★。
 */
export function kpiFromCleanStats(
  bucket: CleanStatsBucket,
  prev: SummaryRatios | undefined
): ReportKpi {
  return {
    operation_rate: toKpiValue(
      operationRate(bucket.completed, bucket.planned),
      prev?.operation_ratio ?? null
    ),
    launch_rate: toKpiValue(
      launchRate(bucket.executed, bucket.planned),
      prev?.launch_ratio ?? null
    ),
    completion_rate: toKpiValue(meanCleanRatio(bucket), prev?.clean_ratio ?? null),
    error_rate: toKpiValue(
      errorRate(bucket.errorCount, bucket.planned),
      prev?.error_ratio ?? null
    ),
  };
}

// ---------------------------------------------------------------- 連続問題日数（facilityPerformance用）

/**
 * `computeConsecutiveProblemDays()` に渡す1件（本清掃）の最小限の形。
 * ★`facility_id` を持たせているのは、期間内で「その施設の最終日」を選ぶため★。
 * ★`ConsecutiveInput`（`lib/db/clean-log-mapping.ts`）の分割キーも `facility_id` になった
 *   （指示書V 不具合B）ので、`ConsecutiveInput` はこの型が要求するフィールドをすべて持つ。
 *   `lib/db/index.ts` では詰め替えず `ConsecutiveInput[]` をそのまま渡している。
 */
export interface FacilityConsecutiveEntry {
  id: number;
  facility_id: number;
  /** ISO8601（+09:00）。`clean_timetables.start_at` 由来 */
  scheduled_at: string;
}

/**
 * 期間内で、その施設の「最終エントリ」（scheduled_at が最大の1件）を返す。
 * 1件も無ければ `undefined`（呼び出し側が0件やnullを組み立てる）。
 *
 * ★`consecutiveProblemDaysAtPeriodEnd()` の選び方の実体はここ1箇所★
 *   （D-103。呼び出し側で「最終エントリを自分で探す」コードを書かないこと）。
 * ★`last_problem_on`（指示書W。`ContinuousProblem`）の算出にも使う★ —
 *   連続日数だけでなく、日付そのものが要る場面はこちらを直接呼ぶ。
 *
 * @param entries 全期間・全施設の行（呼び出し側で施設を絞り込まない）
 * @param range `periodRange()` の戻り値（JST日付の [from, to]、両端含む）
 */
export function lastEntryInPeriod<T extends FacilityConsecutiveEntry>(
  entries: readonly T[],
  facilityId: number,
  range: { from: string; to: string }
): T | undefined {
  let latest: T | undefined;
  for (const entry of entries) {
    if (entry.facility_id !== facilityId) continue;
    const date = entry.scheduled_at.slice(0, 10);
    if (date < range.from || date > range.to) continue;
    if (!latest || entry.scheduled_at > latest.scheduled_at) latest = entry;
  }
  return latest;
}

/**
 * 施設別パフォーマンスの `consecutive_problem_days`（★着手前確認A案。D-103★）。
 *
 * ★`computeConsecutiveProblemDays()` は「その清掃ログの時点までの連続日数」を1件ごとに返す★
 *   （`cleanLogs()` と全く同じ関数・全く同じ入力の作り方。値の意味が一致する）。
 * `facilityPerformance()` の1行は「期間」を表すため、この関数で
 * ★「期間内でその施設の最終清掃ログ」を選び、その1件の連続日数を採用する★
 * （＝「その期間の最終日時点の連続日数」）。★選び方自体は `lastEntryInPeriod()` に委ねる★。
 *
 * ★期間内にその施設の清掃ログが1件も無ければ 0★（連続なしと同義に扱う。
 *   TeamWorksのサマリにこの値は存在しないため、0か算出済みの値かのどちらかになる。
 *   着手前確認のC案（`number | null` にして「未算出」を区別する）は不採用のため、
 *   ここでも null は使わない）。
 */
export function consecutiveProblemDaysAtPeriodEnd(
  entries: readonly FacilityConsecutiveEntry[],
  consecutiveById: ReadonlyMap<number, number>,
  facilityId: number,
  range: { from: string; to: string }
): number {
  const latest = lastEntryInPeriod(entries, facilityId, range);
  if (!latest) return 0;
  return consecutiveById.get(latest.id) ?? 0;
}

/**
 * 「その施設の全期間の行」から、`endDate` で終わる連続 `consecutiveDays` 日ぶんの日付を返す
 * （指示書W。`ContinuousProblem` の summary/launch_failed_days の材料）。
 *
 * ★★`facilityRows` には期間内だけでなく**その施設の全期間の行**を渡すこと★★ —
 *   ランは期間をまたぐ（前月から続いている連続が、期間内の行だけで畳むと途中で切れる）。
 *   `consecutive_problem_days` は全期間走査で出しているので、日数だけ合わせて
 *   ランの日付を期間内だけに絞ると `{m}`（`launchFailedDaysInRun()`）が過小になる。
 *
 * ★内部で `foldProblemDaysByDate()` を呼ぶ★（独自に日付を数え直さない。D-109の問題の再発防止）。
 * `endDate` が畳み込み後の日付に存在しなければ `[]`（呼び出し側は同じ行から取った日付を渡す
 * 契約なので、通常は起きない。例外にしない）。
 *
 * @param facilityRows その施設ぶんの `ConsecutiveInput[]`（全期間）
 * @param endDate `YYYY-MM-DD`。ランの最終日（`lastEntryInPeriod()` の日付）
 * @param consecutiveDays `computeConsecutiveProblemDays()` が返した連続日数
 */
export function problemRunDates(
  facilityRows: readonly ConsecutiveInput[],
  endDate: string,
  consecutiveDays: number
): string[] {
  const days = foldProblemDaysByDate(facilityRows);
  const endIndex = days.findIndex((d) => d.date === endDate);
  if (endIndex === -1) return [];
  const startIndex = Math.max(0, endIndex - consecutiveDays + 1);
  return days.slice(startIndex, endIndex + 1).map((d) => d.date);
}

/**
 * ラン（`problemRunDates()` の戻り値）のうち、出発失敗（未実施）があった日数を返す。
 *
 * ★出発失敗＝`departed === false`★（【未決1】案A。D-156。指示書AS 段階3-4）—
 *   ★`completion_rate === null` で数えないこと★。`lib/db/clean-log-mapping.ts` の不変条件は
 *   ★`departed === false` ⟺ `completion_rate === null`★（P-5=B で `task_area = 0` が 0% になり、両向きに戻った。D-158）。
 *   ★それでも `departed` で数える★（指示書AU D3）— 完了率の規則がまた変わっても、未実施の数え方が
 *   巻き込まれないため（1つの値を2つの意味に兼用しない）。
 * ★同じ日に「未実施の行」と「実施した行」が混在する場合、その日は出発失敗の日として
 *   1日だけ数える（OR。D-109 決定2 の同一日畳み込みと同じ規則）★。
 * ★ランの外の日は数えない★（`runDates` に含まれる日付だけを対象にする）。
 *
 * @param facilityRows その施設ぶんの `ConsecutiveInput[]`（全期間。`problemRunDates()` と同じ入力でよい）
 * @param runDates `problemRunDates()` の戻り値
 */
export function launchFailedDaysInRun(
  facilityRows: readonly ConsecutiveInput[],
  runDates: readonly string[]
): number {
  const runDateSet = new Set(runDates);
  const failedByDate = new Map<string, boolean>();
  for (const row of facilityRows) {
    const date = row.scheduled_at.slice(0, 10);
    if (!runDateSet.has(date)) continue;
    const isFailed = !row.departed;
    failedByDate.set(date, (failedByDate.get(date) ?? false) || isFailed);
  }
  let count = 0;
  for (const failed of failedByDate.values()) {
    if (failed) count += 1;
  }
  return count;
}

/**
 * `consecutive_problem_days` と `launch_failed_days` から `ContinuousProblem.summary` の
 * テンプレート文字列を選ぶ（指示書W 確定1・確定2・確定3）。
 *
 * ★戻り値は数値を埋めていないテンプレートのまま★（方式A。画面側が `{n}` `{m}` を差し込む）。
 * ★前提: `n >= 1` かつ `0 <= m <= n`★（`n <= 0` では呼ばれない。呼び出し側が行を作らない。決定6）。
 *
 * | 条件 | 戻り値 |
 * |---|---|
 * | 出発失敗の日が1日も無い（`m === 0`） | `SUMMARY_TEMPLATE_COMPLETION` |
 * | 全日が出発失敗（`m === n`） | `SUMMARY_TEMPLATE_LAUNCH_FAILED` |
 * | 混在（C案。2026-09-11 ユーザー確定） | `SUMMARY_TEMPLATE_MIXED` |
 *
 * ★★却下した2案★★: (a)「出発失敗を優先する」（D-111項2の決定4）— 直近に清掃を実施していても
 *   「出発失敗」と書くことになり事実と食い違う。(b)「完了率が△以下」で固定 — 出発失敗を隠す。
 */
export function continuousProblemSummary(
  consecutiveDays: number,
  launchFailedDays: number
): string {
  if (launchFailedDays === 0) return SUMMARY_TEMPLATE_COMPLETION;
  if (launchFailedDays === consecutiveDays) return SUMMARY_TEMPLATE_LAUNCH_FAILED;
  return SUMMARY_TEMPLATE_MIXED;
}

/**
 * 清掃ログの日付（`YYYY-MM-DD`。重複していてよい）から、週次+月次の期間キーを列挙する。
 *
 * ★★2026-09-13（指示書AC 段階5）以降、`continuousProblems()` はこの関数を呼ばない★★ —
 *   ★期間が引数（`ReportDbQuery`）で決まるようになったので、要求された1期間だけを
 *   組み立てる★（全期間ぶんの行を作ってからルート側で1期間を選ぶのをやめた）。
 *   ★★関数とテストは残してある★★ — 「日付の集合 → 週次+月次のキー」という規則は
 *   ★`weekOfMonth()` を必ず通す★という規律込みで固定する価値があり、
 *   ★全期間ぶんの行が要る用途（バッチ・検証スクリプト）が出たときの受け皿になるため。
 *   ★使うときは必ずこの関数を経由し、「1日から7日ごと」と書き直さないこと★。
 *
 * ★`continuousProblems()` に対応するサマリテーブルが無いため（D-102項4）、
 *   `facilityPerformance()`（サマリ行から期間キーを列挙。`lib/db/index.ts`）とは違い、
 *   詳細行の日付そのものから列挙する必要がある★。
 * ★★週番号は必ず `weekOfMonth()` に通す★★ — 月跨ぎ週の帰属を知っているのは
 *   `weekOfMonth()` だけ（D-071 / `CLAUDE.md` §4.7）。自分で「1日から数えて7日ごと」と
 *   書かないこと。
 * ★`todayJst()` は呼ばない★ — 当期を特別扱いしない（決定2。確定期間も当期も同じ処理で作る）。
 * ★年次（`month=0 && week=0`）のキーは作らない★ — この関数は常に実在する日付から
 *   `weekOfMonth()` で年月を導くため、`month=0` は構造的に発生しない。
 *   ★2026-09-30 に年次を解禁した（D-151）が、この関数は変えていない★ — ★アプリ内に呼び出し元が
 *   無い★（定義とテストのみ）ため。年次キーが要る呼び出し元を足すときに、ここも年次を作るよう直すこと。
 *   旧: 「P1では読まない。D-072項4」— D-151 で破棄。
 *
 * @returns `year` → `month` → `week`（`week:0` の月次が先頭）の昇順。重複なし
 */
export function reportPeriodKeysFromDates(
  dates: readonly string[]
): ReportPeriodKey[] {
  const byKey = new Map<string, ReportPeriodKey>();
  for (const date of dates) {
    const { year, month, week } = weekOfMonth(date);
    const monthly: ReportPeriodKey = { period: "monthly", year, month, week: 0 };
    const weekly: ReportPeriodKey = { period: "weekly", year, month, week };
    byKey.set(periodMapKey(monthly.period, monthly.year, monthly.month, monthly.week), monthly);
    byKey.set(periodMapKey(weekly.period, weekly.year, weekly.month, weekly.week), weekly);
  }
  return [...byKey.values()].sort(
    (a, b) => a.year - b.year || a.month - b.month || a.week - b.week
  );
}

/**
 * 1期間ぶんの `ContinuousProblem[]` を組み立てる（指示書W 段階5）。
 *
 * ★★DB を読む `continuousProblems()`（`lib/db/index.ts`）の中に直接書くと、
 *   「連続0日は行を作らない」「ロボット0台の施設も出す」「L1もデータ層は返す」を
 *   テストできない★★（禁止事項「テストをDB依存にしない」）ため、純粋関数に切り出してある。
 *   `continuousProblems()` は「DBから材料を集めてこの関数を呼ぶだけ」にすること。
 *
 * ★組み立ての順序（この順で）★:
 *   1. `facilityNameById` を回す（`entries` を回さない — 名前の無い施設の行を作らないため。
 *      `entries` にあるが `facilityNameById` に無い施設idは無視する）
 *   2. `consecutiveProblemDaysAtPeriodEnd()` で期末時点の連続日数を求め、`<= 0` なら `continue`
 *   3. `judgeEscalationLevel()` で L1/L2/L3 を求め、`null`（0日）なら `continue`
 *      （★`as` キャストや `?? "L1"` で握り潰さない。決定6★）
 *   4. `lastEntryInPeriod()` で期間内の最終エントリを取得。無ければ `continue`
 *      （2 で0日を弾いているので通常は起きないが、`!` や `as` は使わない）
 *   5. `facilityRows = entries.filter(e => e.facility_id === facilityId)`
 *      ★★その施設の**全期間**の行を渡す★★ — ランは期間をまたぐ（前月から続く連続を
 *      期間内だけで畳むと途中で切れ、`launch_failed_days` が過小になる）
 *   6. `problemRunDates()` でランの日付配列を求める
 *   7. `launchFailedDaysInRun()` で `{m}` を求める
 *   8. `continuousProblemSummary()` で summary テンプレートを選ぶ
 *   9. 行を積む
 *
 * ★ロボット0台の施設を除外しない（決定5）★ — `facilityNameById` は呼び出し側
 *   （`prismaSource.continuousProblems()`）が `robots` を join せずに作る。この関数は
 *   `facilityNameById` にある施設をそのまま対象にするだけで、robots の有無を一切見ない。
 * ★「2日以上」の絞り込み・並べ替えはこの関数の責務ではない★（決定4。`lib/reports/query.ts` の仕事）。
 *   戻りの順序は `facilityNameById` の反復順（＝ Map の挿入順）のまま。
 *
 * @param entries 過去の行のみ・全施設・全期間の `ConsecutiveInput[]`
 * @param consecutiveById `computeConsecutiveProblemDays()` の戻り値
 * @param facilityNameById 施設id → 施設名（★ロボット0台の施設も入っている★）
 * @param range `periodRange()` の戻り値（JST日付の [from, to]、両端含む）
 */
export function continuousProblemsInPeriod(
  entries: readonly ConsecutiveInput[],
  consecutiveById: ReadonlyMap<number, number>,
  facilityNameById: ReadonlyMap<number, string>,
  range: { from: string; to: string }
): ContinuousProblem[] {
  const result: ContinuousProblem[] = [];
  for (const [facilityId, facilityName] of facilityNameById) {
    const days = consecutiveProblemDaysAtPeriodEnd(entries, consecutiveById, facilityId, range);
    if (days <= 0) continue;

    const level = judgeEscalationLevel(days);
    if (level === null) continue;

    const latest = lastEntryInPeriod(entries, facilityId, range);
    if (!latest) continue;

    const facilityRows = entries.filter((e) => e.facility_id === facilityId);
    const runDates = problemRunDates(facilityRows, latest.scheduled_at.slice(0, 10), days);
    const launchFailedDays = launchFailedDaysInRun(facilityRows, runDates);
    const summary = continuousProblemSummary(days, launchFailedDays);

    result.push({
      facility_id: facilityId,
      facility_name: facilityName,
      consecutive_problem_days: days,
      escalation_level: level,
      last_problem_on: latest.scheduled_at.slice(0, 10),
      summary,
      launch_failed_days: launchFailedDays,
    });
  }
  return result;
}

/**
 * `ContinuousProblem[]` に★表示上限★をかける（指示書AF 段階5-2）。★純粋関数★。
 *
 * ★★呼ぶ場所は「絞り込み → 並べ替え → 切り出し」の**後**★★ —
 *   `sortContinuousProblems()` は `consecutive_problem_days` の降順で並べるので、
 *   ★先に頭打ちにすると上限に達した施設どうしの順序が潰れます★
 *   （45日 / 31日 が両方 30日になり、元の重さの差が消える。指示書AF §G 落とし穴2）。
 *   `filterContinuousProblems()` の「2日以上」も生値で判定させる。
 *
 * ★★`escalation_level` は触らない★★ — L1〜L3 は★上限をかける前の生値★で判定済み
 *   （`CLAUDE.md` §4.3 / 指示書AF 禁止事項9）。ここで作り直すと
 *   「3日以上と出ているのに Lv3 が出ない」になる。
 *
 * ★上限に達した行は `launch_failed_days` を**落とす**★（指示書AF §B-5）:
 *   `d-watch-api.yaml` が `0 <= launch_failed_days <= consecutive_problem_days` を定めており、
 *   `n` を頭打ちにするとこの不等式が壊れる（実際45日・うち40日が出発失敗 → `n=30` で `m=40 > n`）。
 *   `min(m, n)` に丸めるのは「直近n日のうち何日が出発失敗か」の捏造になる。
 *   `launch_failed_days` はもともと任意項目なので、省略しても契約違反にならない。
 *
 * @param items ★絞り込み・並べ替え・切り出し済み★の配列
 * @param maxDays 表示上限（`loadConsecutiveDaysMax()` の戻り値を渡す。既定値を直書きしない）
 */
export function capContinuousProblems(
  items: readonly ContinuousProblem[],
  maxDays: number
): ContinuousProblem[] {
  return items.map((item) => {
    const { days, capped } = capConsecutiveDays(item.consecutive_problem_days, maxDays);
    if (!capped) {
      // ★他のフィールドは一切作り直さない★（summary も launch_failed_days もそのまま）
      return { ...item, consecutive_problem_days_capped: false };
    }
    // ★`launch_failed_days` はキーごと落とす★（`undefined` を入れるのではなく列挙しない。
    //   JSON に `"launch_failed_days": null` が出ないようにするため）
    return {
      facility_id: item.facility_id,
      facility_name: item.facility_name,
      consecutive_problem_days: days,
      consecutive_problem_days_capped: true,
      // ★生値で判定済みの結果をそのまま運ぶ（判定し直さない）★
      escalation_level: item.escalation_level,
      last_problem_on: item.last_problem_on,
      summary: cappedSummaryTemplate(item.summary),
    };
  });
}

/**
 * 上限で頭打ちになったときの summary テンプレートを選ぶ（`capContinuousProblems()` 専用）。
 *
 * ★元のテンプレートからの1対1の差し替え★ — 日数と出発失敗日数の関係は変わらないので、
 *   どのテンプレートが選ばれていたかだけを見れば足りる（`continuousProblemSummary()` を
 *   もう一度呼ぶ必要は無い）。★未知の文字列はそのまま返す★（作り話をしない）。
 */
function cappedSummaryTemplate(summary: string): string {
  if (summary === SUMMARY_TEMPLATE_COMPLETION) return SUMMARY_TEMPLATE_CAPPED_COMPLETION;
  if (summary === SUMMARY_TEMPLATE_LAUNCH_FAILED) return SUMMARY_TEMPLATE_CAPPED_LAUNCH_FAILED;
  if (summary === SUMMARY_TEMPLATE_MIXED) return SUMMARY_TEMPLATE_CAPPED_MIXED;
  return summary;
}

// ---------------------------------------------------------------- エラー種別内訳

/** `facility_error_summaries` 1行ぶんの最小限の形（`customer_error_summaries` は使わない） */
export interface ErrorSummaryRowLike {
  year: number;
  month: number;
  week: number;
  /** ★DBの生値。7分類へのマッピングも日本語化もしない（D-077/D-088）★ */
  error_type: string;
  error_count: number;
}

/**
 * 件数の `Map`（`error_type` → 件数）→ `ErrorBreakdownItem[]`。
 *
 * ★`type` と `label` の両方に同じ生値を入れる★（D-077/D-088。7分類にも日本語にも変換しない）。
 * ★`rate` は丸めない生値★（CLAUDE.md §4.3）。★分母はこの Map 内の全件数の合計★
 *   （`reduce` で出す。合計を定数に置かない。`lib/fixtures/reports.ts:478` と同じ作法）。
 * 件数が1件も無ければ空配列（該当0件の表示。`errorBreakdownEmpty` と同じ扱い）。
 */
export function errorBreakdownItemsFromCounts(
  counts: ReadonlyMap<string, number>
): ErrorBreakdownItem[] {
  const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
  return [...counts].map(([type, count]) => ({
    type,
    label: type,
    count,
    rate: total === 0 ? 0 : (count / total) * 100,
  }));
}

/**
 * `facility_error_summaries` の全行から、期間ごとに同一 `error_type` を全施設ぶん足し上げる
 * （★facility版で確定。D-102項4の解決 — `facilityPerformance()` と粒度を揃える★）。
 *
 * ★履歴から再計算しない（サマリの `error_count` をそのまま合算するだけ。D-102）★。
 * ★年次（`month===0 && week===0`）の行もそのまま渡してよい★（2026-09-30 に年次を解禁。D-151）。
 * 粒度の判別は `periodOfSummaryRow()` が `month` も見て行うので、年次が月次に化けることはない。
 * 旧: 「呼び出し側で年次を除外してから渡すこと（P1では読まない。D-072項4）」— D-151 で破棄。
 */
export function errorBreakdownRowsFromSummary(
  rows: readonly ErrorSummaryRowLike[]
): Array<ReportPeriodKey & { items: ErrorBreakdownItem[] }> {
  const countsByPeriodKey = new Map<string, Map<string, number>>();
  const periodInfoByKey = new Map<string, ReportPeriodKey>();

  for (const row of rows) {
    const period = periodOfSummaryRow(row);
    const key = periodMapKey(period, row.year, row.month, row.week);
    if (!periodInfoByKey.has(key)) {
      periodInfoByKey.set(key, { period, year: row.year, month: row.month, week: row.week });
    }
    const byType = countsByPeriodKey.get(key) ?? new Map<string, number>();
    byType.set(row.error_type, (byType.get(row.error_type) ?? 0) + row.error_count);
    countsByPeriodKey.set(key, byType);
  }

  const result: Array<ReportPeriodKey & { items: ErrorBreakdownItem[] }> = [];
  for (const [key, byType] of countsByPeriodKey) {
    const periodInfo = periodInfoByKey.get(key);
    // ★periodInfoByKeyはcountsByPeriodKeyと同じループで両方セットしているので必ず存在する★
    if (!periodInfo) continue;
    result.push({ ...periodInfo, items: errorBreakdownItemsFromCounts(byType) });
  }
  return result;
}

/** `robot_error_reports` 1行ぶんの最小限の形（当期のその場集計用） */
export interface ErrorReportLike extends PrecheckDetectedAtSource {
  /** ★DBの生値。7分類へのマッピングも日本語化もしない（D-077/D-088）★ */
  error_type: string;
}

/**
 * 当期（詳細テーブル）のエラー内訳を ★本清掃行を指すエラーだけ★ `error_type` ごとに数える。
 *
 * ★★分子の集合は `errorCountByMainTimetable()` と同じ★★（決定2）—
 *   同じレポート画面（F-06）に定義の違う2つの数字を並べないため。
 *   ★事前チェック行を指すもの / `null` / dangling は数えない★（TeamWorks 第8回 A7・決定3）。
 *
 * ★★期間帰属は「そのエラーが指す本清掃行の `start_at` の JST 日付」★★（決定7）—
 *   エラー率の分母は清掃予定回数（P-6=B。`CLAUDE.md` §4.2）なので、分子も予定（本清掃）を基準にする。
 *   ★`errored_at` 基準ではない★（深夜清掃が日をまたぐと分子と分母が別の期間に入るため）。
 *   `reportKpis()` / `facilityPerformance()` は `aggregateCleanStatsByFacility()` が
 *   本清掃行を期間で絞ってからエラー件数を足すので ★すでにこの帰属★。そちらに揃える向き。
 * ★`jstIsoFromDb()` で日本時間の壁時計値に補正してから日付の桁を取る★
 *   （TeamWorks 第8回 A2。指示書Y）。`YYYY-MM-DD` は辞書順＝日付順。
 * ★`type` / `label` は生値のまま。7分類・日本語への変換はしない（D-077 / D-088）★
 */
export function countMainErrorTypesInRange(
  errorReports: readonly (ErrorReportLike & { clean_timetable_id: bigint | null })[],
  mainStartAtById: ReadonlyMap<string, Date>,
  range: { from: string; to: string }
): Map<string, number> {
  const counts = new Map<string, number>();
  for (const report of errorReports) {
    if (report.clean_timetable_id === null) continue;
    const startAt = mainStartAtById.get(report.clean_timetable_id.toString());
    // ★事前チェック行を指すもの・dangling はここで落ちる（肯定条件の自然な帰結）★
    if (startAt === undefined) continue;
    const jstDate = jstIsoFromDb(startAt).slice(0, 10);
    if (jstDate < range.from || jstDate > range.to) continue;
    counts.set(report.error_type, (counts.get(report.error_type) ?? 0) + 1);
  }
  return counts;
}
