/**
 * アラート（`PrecheckItem`）を組み立てるときの変換ロジック（純粋関数のみ）。
 *
 * ★`lib/db/ds-status.ts` / `lib/db/clean-log-mapping.ts` と同じ作法★ —
 * DB アクセスを混ぜず、`prismaSource.prechecks()` から呼ぶだけにして、
 * テストは純粋関数だけで書けるようにする（指示書S 3-5 / 禁止事項12）。
 *
 * ★`clean-log-mapping.ts` に足さず別ファイルにした理由★（指示書S 3-5 の判断事項）:
 *   `cleanLogs()` と `prechecks()` は「同じ出来事の別の切り口」だが、
 *   変換規則は別物（あちらは判定・連続日数、こちらは severity と分数）。
 *   混ぜるとテストの期待値表がどちらのものか分からなくなる。
 *   ★両方が使う規則（`robotStatusFromErrors` / `departedFromHistory`）は
 *     `clean-log-mapping.ts` に置いたまま共有する★（D-103。2箇所に書かない）。
 */
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import { SUCCESS_GRADES } from "@/lib/dashboard/aggregate";
import {
  completionRateFromReport,
  notExecutedFromReport,
  robotStatusFromErrors,
  type CleanTaskReportLike,
  type RobotErrorLike,
} from "@/lib/db/clean-log-mapping";
import { jstIsoFromDb } from "@/lib/format/date";
import { judgeGrade } from "@/lib/judge";
import type { PrecheckItem, PrecheckStatus } from "@/lib/types";

/**
 * `robot_error_reports.status`（0/1/2） → `PrecheckItem.resolution_status`
 *
 * ★値域は 0/1/2★。変換先は `unresolved` / `in_progress` / `resolved`
 *   （`PrecheckStatus`。エラー一覧・D-Sec アラート・事前チェックで共通。指示書AX D8）。
 *
 * ★想定外の値は静かに握りつぶさず例外を投げる★（`ds-status.ts` と同じ）。
 *   `@db.UnsignedTinyInt` は 0〜255 を許すため、3以上が入りうる。
 *   既定値に倒すと「対応済みなのに未解消と出る」ような取り違えに誰も気づけない。
 */
export function resolutionStatusFromRobotErrorStatus(status: number): PrecheckStatus {
  switch (status) {
    case 0:
      return "unresolved";
    case 1:
      return "in_progress";
    case 2:
      return "resolved";
    default:
      throw new Error(
        `[precheck-mapping] robot_error_reports.status に想定外の値です: ${status}（0=未解消/1=対応中/2=解消のみ想定）`
      );
  }
}

/**
 * `PrecheckItem.resolution_status` → `robot_error_reports.status`（seed とアプリの書き込みで使う逆変換）
 *
 * ★アプリの書き込み★は `lib/db/robot-error-write.ts`（`PATCH /prechecks/{id}/status`。指示書AT）。
 */
export function robotErrorStatusFromResolutionStatus(status: PrecheckStatus): number {
  switch (status) {
    case "unresolved":
      return 0;
    case "in_progress":
      return 1;
    case "resolved":
      return 2;
  }
}

/** `robot_error_reports.status` の既知の状態。★`PrecheckStatus` の3つから作る（直書きしない）★ */
const KNOWN_ROBOT_ERROR_STATUSES: readonly PrecheckStatus[] = ["unresolved", "in_progress", "resolved"];

/**
 * ★`robot_error_reports.status` の既知のコード（0/1/2）★（指示書BB U3・D8・D-182）。
 * SQL の `status: { in: … }`（写し）に使う。★0/1/2 を直書きしない★（`robotErrorStatusFromResolutionStatus()` から作る）。
 * ★`errorReportStatusCodes()`（`lib/error-reports/query.ts`）をここから import しない★ — あちらがこのファイルを import しているので循環する。
 */
export function knownRobotErrorStatusCodes(): number[] {
  return KNOWN_ROBOT_ERROR_STATUSES.map(robotErrorStatusFromResolutionStatus);
}

/**
 * ★`robot_error_reports.status` が既知（0/1/2）か★（指示書BB U3・D-182）。
 * ★一覧は外す・1件読みは404★ — 1行の想定外の値で画面全体を500にしない（エラー一覧の表2 F12・D-Sec の D3 と同じ考え方）。
 * `resolutionStatusFromRobotErrorStatus()` は呼ぶ前にこれで外すこと（あちらの例外は残す）。
 */
export function isKnownRobotErrorStatus(status: number): boolean {
  return knownRobotErrorStatusCodes().includes(status);
}

/**
 * `clean_timetables.precheck` → `PrecheckItem.precheck_minutes`（何分前のチェックか）
 *
 * ★関数名の `Code` は歴史的な名前★ — 以前は `precheck` を回次のコード値として読んでいた
 * 名残で、現在の `precheck` はコード値ではなく分数そのもの。呼び出し側（`lib/db/index.ts`）に
 * `precheckMinutes` という局所変数があり素直な短い名前が取れないため、この作業では改名しない
 * （申し送り。次に本ファイルを触るときに一緒に直すのが安全）。
 *
 * ★`clean_timetables.precheck` は「本清掃開始の何分前か」を表す数値★
 *   （TeamWorks 第7回回答 A5-2「既にclean_timetablesに格納するデータは本清掃開始〇分前の
 *   〇部分の数値が入るように修正済みです」/ A5-5「0以外の予約語はありません。すべて本清掃を
 *   基準に何分前開始の値が格納されます」。2026-09-11 受領。D-112）。
 *
 * ★`CLEAN_TIMETABLE_PRECHECK.main`（＝本清掃）だけが `null`★。
 *   呼び出し側はこの `null` を「アラートの対象外」として扱う
 *   （出発失敗は**エラー行からではなく**本清掃行の履歴の有無から作る。D-103）。
 *
 * ★★`1` は「1回目」ではなく「1分前」（A5-3「もし1や2が格納されていたら1分, 2分前として
 * ください」）★★ — ★★以前の実装は `1` を60分前・`2` を30分前と読んでおり、TeamWorks の指示と
 * **真逆**だった（指示書X）★★。
 *
 * ★新旧の混在は無い（A5-4「混在しません」）ので、値域を吸収する仕掛けは持たない★ —
 *   以前あった「コード値表（`CLEAN_TIMETABLE_PRECHECK.rounds`）を引き、無ければ生値」という
 *   2段構えの変換は撤回した。値は常に「本清掃だけ `null`、それ以外は生値そのまま」の1本。
 *
 * ★未知の値を「その他」に丸めない（D-077 / D-089 項3 / D-104 項2）★。
 *   A5-5 が「0以外の予約語はありません」と明言しており、丸める先が存在しない。
 * ★負の値も含めて生値をそのまま返す。例外を投げない★ —
 *   `precheck` は `UnsignedTinyInt`（`prisma/schema.prisma`）なので負の値はそもそも
 *   格納できず、負の値が来るとしたらこの関数より手前の異常である。これは3値の enum に
 *   変換する `resolutionStatusFromRobotErrorStatus()` とは事情が違う連続量であり、
 *   生値がそのまま画面（`precheckSeverityLabel()`）に出れば人が気づける（fail-loud）。
 */
export function precheckMinutesFromCode(precheck: number): number | null {
  if (precheck === CLEAN_TIMETABLE_PRECHECK.main) return null;
  return precheck;
}

/**
 * 詳細ログの事前チェック列の数（`status_1h` / `status_30m` の2列）。
 *
 * ★閾値ではなく API の形（フィールドが2つ）★なので `lib/config` には置かない。
 * 事前チェックは1清掃につき2回で固定と確定している
 * （ユーザー回答 2026-09-11「これは2回だけです、3回以上にはしません」）。
 */
export const PRECHECK_COLUMN_COUNT = 2;

/** `statusColumnsFromPrecheckRows()` の戻り（★添字0 ＝ 列1（1回目）・添字1 ＝ 列2（2回目）★） */
export interface PrecheckColumns {
  /** `[status_1h, status_30m]`。★予定行が無い回は `null`★（D-171） */
  status: [string | null, string | null];
  /** `[status_1h_minutes, status_30m_minutes]`（`precheck` の生値）。★`status` が null の回だけ `null`★ */
  minutes: [number | null, number | null];
  /**
   * ★列に入りきらず捨てた分数★（分数の大きい順）。3つ以上の事前チェックがあったときだけ空でない。
   * ★呼び出し側（`lib/db/index.ts`）は空でなければ `data.anomaly` を出す★（黙って捨てない）
   */
  dropped: number[];
}

/**
 * 事前チェック行（`precheck` の生値ごとのエラー配列） → 詳細ログの事前チェック2列（D-178。ユーザー判断 2026-10-16「案1」）
 *
 * ★`status_1h` / `status_30m` という**フィールド名は API 契約**（`d-watch-api.yaml`）なので互換のため残す★が、
 *   ★中身はもう「60分前 / 30分前」ではなく「1回目 / 2回目」★。何分前かは `minutes` で返し、
 *   API の `status_1h_minutes` / `status_30m_minutes` になる。
 *
 * ★割り当て規則（順番で割り当てる）★:
 *   1. Map のキー（`precheck` の生値）から本清掃（`CLEAN_TIMETABLE_PRECHECK.main`）を除く
 *      （呼び出し側も除いているが、ここでも弾く）
 *   2. ★分数の大きい順（＝早い順）に並べる★。Map の挿入順には頼らない
 *   3. 3つ以上あれば ★開始に近い（分数の小さい）2つを残し★、残りを `dropped` で返す
 *      （1清掃2回で固定なので通常は起きない。★黙って捨てず、呼び出し側がログに出す★）
 *   4. 1つ目を列1（`status_1h`）、2つ目を列2（`status_30m`）に入れる。
 *      ★1つしか無ければ、その分数によらず列1に入り、列2は `null`★。無ければ両方 `null`
 *
 * ★なぜ分数の一致（旧: `PRECHECK_MINUTES_BEFORE` 基準）をやめたか★ —
 *   事前チェックを何分前に行うかは設定で変わりうる（例: 30分前を10分前に）。旧規則では
 *   DB が 60/10 になると ★10分前の結果が例外もテスト失敗も出ないまま落ち、2列目が「-」になった★。
 *   ★見出しを設定から作る案（案2）は採らなかった★ — 施設ごとに分数が違う（A店 60/30・B店 60/10）と、
 *   どちらかが必ず「-」になる（D-178）。
 *
 * ★値（列の中身）は今までどおり★:
 *   - ★予定行がある回は、エラーが無ければ待機（`robotStatusFromErrors([])`）、あれば `robotStatusFromErrors()`★
 *   - ★予定行が無い回は `null`★（ユーザー指示 2026-10-07「事前チェックがないものは-にしてください」。D-171）
 *   - ★「行がある」は Map にキーがあること★。呼び出し側（`lib/db/index.ts`）は予定行ごとに、
 *     エラーが無くても空配列でキーを入れる。★エラーの有無でキーを入れ分けないこと★
 *     （入れ分けると、予定があって異常なしの回が消え、列がずれる）
 *   - ★分数は変換しない（D-077）★。旧コード値の `1` / `2` も1分前・2分前のまま（`precheckMinutesFromCode()` と同じ。
 *     TeamWorks 第7回 A5-3）。★その結果 2分前が列1・1分前が列2 になる★（fail-loud。読み替えない）
 *
 * ★判定には使わない★（D-170）— 列の割り当てが変わっても ◎○△× と出発成功・失敗は変わらない。
 *
 * ★期待値表 P1〜P11（D-178）は `lib/db/__tests__/precheck-mapping.test.ts`★
 *
 * @param errorsByPrecheck `precheck` の生値 → その行に紐づくエラー配列（★予定行があればエラーが無くても空配列で入る★）
 */
export function statusColumnsFromPrecheckRows(
  errorsByPrecheck: ReadonlyMap<number, readonly RobotErrorLike[]>
): PrecheckColumns {
  // ★分数の大きい順（早い順）★
  const ordered = [...errorsByPrecheck.keys()]
    .filter((minutes) => minutes !== CLEAN_TIMETABLE_PRECHECK.main)
    .sort((a, b) => b - a);
  // ★3つ以上なら開始に近い（末尾の）2つを残す★
  const keptFrom = Math.max(0, ordered.length - PRECHECK_COLUMN_COUNT);
  const dropped = ordered.slice(0, keptFrom);
  const kept = ordered.slice(keptFrom);

  const column = (i: 0 | 1): { status: string | null; minutes: number | null } => {
    const minutes = kept[i];
    if (minutes === undefined) return { status: null, minutes: null };
    // ★`minutes` は Map のキーから取ったので `get()` は必ず配列を返す★（`?? []` は型を満たすためだけ。予定行の無い回は上で null 済み）
    return { status: robotStatusFromErrors(errorsByPrecheck.get(minutes) ?? []), minutes };
  };
  const first = column(0);
  const second = column(1);
  return {
    status: [first.status, second.status],
    minutes: [first.minutes, second.minutes],
    dropped,
  };
}

/**
 * `robot_error_reports` のうち、`detected_at` を決めるのに必要な最小限の形
 * （`RobotErrorLike`（`clean-log-mapping.ts`）とは用途が違うので別に定義する）
 */
export interface PrecheckDetectedAtSource {
  /** `robot_error_reports.errored_at`（★nullable★。`prisma/schema.prisma:334`） */
  errored_at: Date | null;
  /** `robot_error_reports.created_at`（★NOT NULL / default now()★。`:337`） */
  created_at: Date;
}

/**
 * `PrecheckItem.detected_at` に使う日時を選ぶ（指示書S 3-4 / D-105）
 *
 * ★`errored_at ?? created_at`★ —
 *   `errored_at`（エラーが起きた時刻）が意味的に正しいが ★nullable★ なので、
 *   NOT NULL の `created_at`（行が作られた時刻）を保険にする。
 *   `PrecheckItem.detected_at` は `string`（nullable ではない）ため、
 *   どちらかを必ず選べる形にしておく必要がある。
 *
 * ★`created_at` を優先しない理由★: 収集処理の遅延がそのまま「検知時刻」になり、
 *   実際にエラーが起きた時刻より後ろにずれる。
 *
 * ★★指示書Y（タイムゾーン補正）との関係★★ — `errored_at` は DATETIME、`created_at` は
 *   TIMESTAMP で型が違う。★本番はどちらも JST の壁時計値なので、呼び出し側は戻り値を
 *   同じ `jstIsoFromDb()` に通せばよい（同じ補正でよい）★（TeamWorks第8回A2）。
 *   ★★ただしローカルのシードは `errored_at` を必ず入れるため、`created_at` への
 *   フォールバックが1度も発火しない＝この関数の `created_at` 側の補正はローカルでは
 *   検証できていない★★（指示書Y 申し送り③）。実DB接続時に
 *   `SELECT COUNT(*) FROM robot_error_reports WHERE errored_at IS NULL` を流し、
 *   0件でなければ `detected_at` の値を目で確かめること。
 */
export function detectedAtFromError(source: PrecheckDetectedAtSource): Date {
  return source.errored_at ?? source.created_at;
}

/**
 * `robot_error_reports` の1行のうち、`PrecheckItem`（`severity: "precheck"`）を組み立てるのに要る列。
 * ★Prisma の行（`include: { facility: true }`）がそのまま入る形★。
 */
export interface PrecheckErrorRowSource extends PrecheckDetectedAtSource {
  error_id: string;
  serial_number: string;
  facility_id: bigint;
  facility: { facility_name: string };
  error_type: string;
  status: number;
  responded_at: Date | null;
  resolved_at: Date | null;
  comment: string | null;
}

/**
 * 事前チェック行に紐づくエラー行 → `PrecheckItem`（`severity: "precheck"`）（指示書AT 段階4-1）。
 *
 * ★★`prismaSource.prechecks()`（一覧）と `prismaSource.precheckItemById()`（PATCH の前後の読み取り）が
 *   この1本を使う★★ — ★同じ行の作り方を2箇所に書かない★（片方だけ直すと、更新後に返す行と
 *   一覧の行が食い違う）。
 *
 * @param report エラー行（`include: { facility: true }`）
 * @param precheckMinutes `precheckMinutesFromCode(事前チェック行.precheck)`（★null でないことは呼び出し側が確かめる★）
 * @param mainRow 事前チェック行の `parent_id` が指す本清掃行（`scheduled_at` に使う）
 * @param mainReport その本清掃行の `clean_task_reports`（`latestReportByTimetableId()` で引いた1件。無ければ undefined）。
 *   ★`main_run_succeeded` に使う★（D-171）。★省略可にしない★ — 渡し忘れが「常に false」に化けて気づけないため
 */
export function precheckItemFromErrorRow(
  report: PrecheckErrorRowSource,
  precheckMinutes: number,
  mainRow: { start_at: Date },
  mainReport: CleanTaskReportLike | undefined
): PrecheckItem {
  return {
    // ★`error_id` を使い string のまま★（D-070 1-2）。★`Number()` を通さない★ —
    //   `varchar(50)` なので NaN になっても例外が出ず、静かに壊れる（落ち穴3）
    id: report.error_id,
    severity: "precheck",
    precheck_minutes: precheckMinutes,
    serial_number: report.serial_number,
    // ★BigInt を残すと `NextResponse.json` が例外を投げる（落とし穴4）★
    facility_id: Number(report.facility_id),
    facility_name: report.facility.facility_name,
    // ★`scheduled_at` は事前チェック行の `start_at` ではなく親の本清掃行の値★（指示書S 3-2 項5）
    // ★DBのDATETIMEには日本時間の壁時計値が入っている（TeamWorks第8回A2。指示書Y）★
    scheduled_at: jstIsoFromDb(mainRow.start_at),
    // ★`error_type` の生値。7分類にも日本語にも変換しない★（D-077 / D-088 / D-098）
    //   1件のエラー行なので配列に包んで既存の純粋関数へ渡す
    //   ★errored_at はDB由来の値どうしの相対比較（並べ替え）にしか使わないので補正しない★
    robot_status: robotStatusFromErrors([
      { error_type: report.error_type, errored_at: report.errored_at },
    ]),
    // ★`errored_at ?? created_at`（`detectedAtFromError()` に規則を閉じ込めてある）★
    detected_at: jstIsoFromDb(detectedAtFromError(report)),
    // ★書き込み許可4カラム（D-042）。書くのは `lib/db/robot-error-write.ts`（指示書AT）★
    resolution_status: resolutionStatusFromRobotErrorStatus(report.status),
    responded_at: report.responded_at ? jstIsoFromDb(report.responded_at) : null,
    resolved_at: report.resolved_at ? jstIsoFromDb(report.resolved_at) : null,
    note: report.comment,
    // ★常に null★ — `robots.battery`（`prisma/schema.prisma:242`）は現在値のみで
    //   履歴が無いため「事前チェック時点の残量」を取れない（D-090 項8）。
    //   ★現在値を代入すると、別時点の値を過去のアラートに貼ることになる★
    battery_level: null,
    // ★親の本清掃が成功したか（D-171）★ — 規則は `mainRunSucceededFromReport()` の1本
    main_run_succeeded: mainRunSucceededFromReport(mainReport),
  };
}

/**
 * ★親の本清掃が成功したか★（`PrecheckItem.main_run_succeeded`。ユーザー指示 2026-10-07。D-171）
 *
 * | 親の本清掃 | 戻り |
 * |---|---|
 * | レポート行が無い（未実施＝出発失敗） | `false` |
 * | レポート行があり、完了率の判定が ◎○ | `true` |
 * | レポート行があり、完了率の判定が △× | `false`（`task_area = 0` は完了率 0 → ×） |
 *
 * ★成功の定義を新しく書かない★ — レポート行の有無は `notExecutedFromReport()`、完了率は
 *   `completionRateFromReport()`（＝`runCompletionRate()`。`clean_ratio` 列は読まない。D-155）、
 *   等級は `judgeGrade()`（★丸める前の生値で判定★。`CLAUDE.md` §4.3）、成功とみなす等級は
 *   `SUCCESS_GRADES`（ダッシュボードの「成功」と同じ定数）。
 *
 * ★★表示のためだけの値★★ — 事前チェックは判定に使わない（D-170）のと同じく、
 *   ★この値で `resolution_status` やサマリの件数を変えないこと★（本清掃が成功しても未解消は未解消）。
 *
 * 親の本清掃が出発失敗（レポート行が無い）なら `undefined` が来て `false` になる（M4）。
 * （旧: `launch_failed` の行もこの関数で `false` を得ていた。2026-10-29 にアラートから出発失敗を外した）
 */
export function mainRunSucceededFromReport(report: CleanTaskReportLike | undefined): boolean {
  if (notExecutedFromReport(report)) return false;
  const grade = judgeGrade(completionRateFromReport(report));
  return (SUCCESS_GRADES as readonly string[]).includes(grade);
}
