/**
 * ダッシュボード（F-01）の「直近の清掃」の選び方（D-159 / 指示書AV 段階2-3）。
 * ★純粋関数のみ★（DBアクセスを混ぜない。`CLAUDE.md` §9.4）— fixtures でも Prisma でも同じ規則が走る（§9.5）。
 *
 * 期待値表の正は `lib/dashboard/__tests__/latest-run.test.ts`（表1 は監督確定。指示書AV §3）。
 *
 * ★★時刻の比較は `Date.parse()` したミリ秒で行う★★（指示書AV 4-4）— 文字列で比べない。
 * ★★`now` は呼び出し側（ルート）で1回だけ作ったものを受け取る★★（4-5）— ここで `new Date()` を作らない。
 *   窓・`today` と別の「現在」を使うと、境界でカードと表の母集団がずれる。
 * ★★`LAUNCH_GRACE_MINUTES` を使わない★★（D6 / 4-6）— 出発・◎○△× の判定には一切触れない。
 *
 * ★★「終了＋落ち着き時間（30分）」の待ちは D-173 でやめた★★（ユーザー判断 2026-10-07。表2 C6）—
 *   ★候補は「予定の終了 ≤ 現在」★。終わってからレポートが届くまでのあいだは、行の側の旗
 *   `result_pending`（`isResultPending()`。データソースが詰める）で「結果待ち」と出す。
 *   旧: `DASHBOARD_LATEST_RUN.settleMinutes`（削除済み）。
 *
 * ★★予定外タスクも候補にする★★（指示書BG §2-2。ユーザー指示 2026-10-29）—
 *   開始 ＝ 清掃結果の `started_at`（＝ `CleanLogItem.scheduled_at`）、終了 ＝ `ended_at`（null なら開始。メタの `planned_end_at`）。
 *   ★行とメタは `row_key` で突き合わせる★（予定外の行は `id` が null）。
 */
import { toJstIso } from "@/lib/format/date";
import type { CleanLogItem, CleanLogRunMeta } from "@/lib/types";

const MS_PER_DAY = 86_400_000;

/** `pickLatestRunPerSection()` の設定。値は `DASHBOARD_LATEST_RUN`（`lib/config`）から渡す */
export interface LatestRunOptions {
  /** 現在。★ルートで1回だけ作ったもの★ */
  now: Date;
  /** 何日前の予定まで遡るか（予定の開始で数える。ちょうどは含む） */
  lookbackDays: number;
}

/**
 * 直近モードで DB から読む日付範囲（JST の `YYYY-MM-DD`。両端を含む。表3）。
 *
 * ★窓は `pickLatestRunPerSection()` の判定より広くてよい★（細かい判定は純粋関数の側）。
 * ★`date_from` / `date_to` を `null` にしないこと★ — 全件読みに戻る（指示書AV 4-7）。
 * ★`toJstIso()` で日付を切り出す★（実行環境の TZ に依存しない。`getFullYear()` 等を使わない）。
 */
export function latestRunWindow(
  now: Date,
  lookbackDays: number
): { date_from: string; date_to: string } {
  return {
    date_from: toJstIso(new Date(now.getTime() - lookbackDays * MS_PER_DAY)).slice(0, 10),
    date_to: toJstIso(now).slice(0, 10),
  };
}

/**
 * 同じ清掃エリアで開始が同じ2行のどちらを採るか（D5 を予定外タスクへ広げた。指示書BG §2-2）。★正なら `a` を採る★。
 *
 * - ★予定のある行どうし★: ★id の大きいほう★（D5。今と同じ）
 * - ★予定のある行と予定外の行★: ★予定のある行★（予定外の行の判定は清掃結果そのものなので、予定の結果を優先して見せる。
 *   監督の判断待ちの決め事 — どちらでも件数の合計は変わらない）
 * - ★予定外の行どうし★: ★`report_id` の大きいほう★（桁数 → 辞書順。19桁の文字列を数値にしない）
 */
function preferOnTie(a: CleanLogItem, b: CleanLogItem): boolean {
  if (a.id !== null && b.id !== null) return a.id > b.id;
  if (a.id !== null) return true;
  if (b.id !== null) return false;
  const ra = a.report_id ?? "";
  const rb = b.report_id ?? "";
  return ra.length !== rb.length ? ra.length > rb.length : ra > rb;
}

/**
 * ★清掃エリアごとに、終わった本清掃（★予定外タスクを含む★）のうち開始が一番新しい1件★を選ぶ（指示書AV §2 D1〜D5・指示書BG §2-2）。
 *
 * 候補にする条件（★両方とも「含む」★。D4）:
 *   - 予定の開始 ≥ 現在 − 遡り日数（★予定外は `started_at`★）
 *   - 予定の終了 ≤ 現在（★清掃中は候補にしない★。D-173 で落ち着き時間をやめた。★予定外は `ended_at`（null なら開始）★。表U3 U3-2）
 * 同じエリアで開始が同じなら ★id の大きいほう★（D5。予定外を含むときは `preferOnTie()`）。
 * ★予定外の行は成功（◎○）か失敗（△×）のどちらかになる★（`computeSummary()`。未実施・結果待ちにならない。表U3 U3-1）。
 *
 * ★出発失敗（`departed: false`）も選ぶ★（D2。`computeSummary()` が未実施に数える）。
 * ★結果待ち（`result_pending: true`）も選ぶ★（D-173。`computeSummary()` が `pending` に数える）。
 * ★条件を満たす予定が無いエリアは出さない★（D3。0件の行を作らない）。
 * ★`isFutureSchedule()` は呼ばない★ — 未来の予定は `cleanLogs()` が既に外しており、「終わった」の条件で足りる。
 *
 * ★★不変条件（D9）★★: `rows` の各行に対応する `meta`（★`row_key` が同じもの★）がちょうど1件あること。
 *   ★無い（E1）は選ばない★（`missingRunMetaIds()` で数え、ルートが `data.anomaly` を出す。指示書BB A2・D-182）—
 *   `cleanLogs()` と `cleanLogRunMeta()` は並列に読むので、間に行が増えると起きうる。
 *   ★2件以上（E2）は例外★（2表の重複除去の崩れで、競合では起きない）。
 *   ★逆に、行の無い `meta`（未来の予定など）は無視する★（E6）。
 *
 * ★戻りは入力の順を保つ★（E4。順序の正はデータソース。ルートで並べ替え直さない）。
 * ★行を書き換えない・丸めない★（入力の行オブジェクトをそのまま返す）。
 */
export function pickLatestRunPerSection(
  rows: readonly CleanLogItem[],
  meta: readonly CleanLogRunMeta[],
  options: LatestRunOptions
): CleanLogItem[] {
  const metaByKey = new Map<string, CleanLogRunMeta>();
  for (const m of meta) {
    if (metaByKey.has(m.row_key)) {
      throw new Error(
        `[dashboard] pickLatestRunPerSection: 清掃ログ(row_key=${m.row_key}) の予定の終了・清掃エリアが2件以上あります`
      );
    }
    metaByKey.set(m.row_key, m);
  }

  const nowMs = options.now.getTime();
  const earliestStartMs = nowMs - options.lookbackDays * MS_PER_DAY;

  /** section_key → 選ばれた行（開始のミリ秒と `preferOnTie()` で比べる） */
  const best = new Map<string, { row: CleanLogItem; startMs: number }>();
  for (const row of rows) {
    const m = metaByKey.get(row.row_key);
    // ★メタの無い行は選ばない（例外にしない。指示書BB A2）★ — 件数は `missingRunMetaIds()`、ログはルート
    if (m === undefined) continue;
    const startMs = Date.parse(row.scheduled_at);
    const endMs = Date.parse(m.planned_end_at);
    if (startMs < earliestStartMs) continue;
    if (endMs > nowMs) continue;

    const current = best.get(m.section_key);
    if (
      current === undefined ||
      startMs > current.startMs ||
      (startMs === current.startMs && preferOnTie(row, current.row))
    ) {
      best.set(m.section_key, { row, startMs });
    }
  }

  const pickedKeys = new Set<string>();
  for (const v of best.values()) pickedKeys.add(v.row.row_key);
  return rows.filter((row) => pickedKeys.has(row.row_key));
}

/**
 * ★メタの無い行の `row_key`★（指示書BB A2・D-182）— `cleanLogs()` と `cleanLogRunMeta()` は並列に読むので、間に行が増えると起きうる。
 * `pickLatestRunPerSection()` はその行を選ばない（例外にしない）。★ログはルートが出す★（純粋関数のまま）。
 * ★旧: id を返していた★（予定外タスクの行は id が null なので `row_key` にした。指示書BG §2-2）
 */
export function missingRunMetaIds(rows: readonly CleanLogItem[], meta: readonly CleanLogRunMeta[]): string[] {
  const keys = new Set(meta.map((m) => m.row_key));
  return rows.filter((row) => !keys.has(row.row_key)).map((row) => row.row_key);
}
