import { COMPLETION_DROP, RESCUE_THRESHOLDS } from "@/lib/config";
import { judgeEscalationLevel } from "./escalation";
import { meanCleanRatio } from "./kpi";

/**
 * 要レスキュー店舗の判定（ユーザー指示 2026-09-15 / 指示書AI 段階3）
 *
 * ★★純粋関数だけを置く★★（`CLAUDE.md` §9.4「判定関数は純粋関数にする」）—
 *   DBアクセスも `new Date()` も混ぜない。入力は呼び出し側（`lib/db/`）が作る。
 *
 * 「翌朝DFA運用担当が確認する」運用（`CLAUDE.md` §1）で、★真っ先に見るべき施設★を選ぶ。
 * 確定した3条件（指示書AI §1-1。ユーザー確定 2026-09-15）:
 *
 * | # | 条件 | 判定 |
 * |---|---|---|
 * | R-1 | 未実施が3日以上連続（＝出発失敗と同義） | `departed === false` が3スケジュール日以上連続 |
 * | R-2 | 連続問題レベル3 | `judgeEscalationLevel() === "L3"` |
 * | R-3 | 完了率が前回比20%以上の急落 | `(前回 − 当日) ÷ 前回 ≧ dropRatio` |
 *
 * ★★R-1 の「未実施」は `departed` で数える★★（【未決1】案A。D-156。指示書AS 段階3-4）—
 *   ★`completion_rate === null` で数えないこと★。いまは両者が同値（P-5=B で `task_area = 0` が 0% になった。
 *   D-158）だが、★完了率の規則がまた変わっても、未実施の数え方が巻き込まれないため★（指示書AU D3）。
 *
 * ★★R-3 の「完了率」は1回ごとの完了率（`runCompletionRate()`）の単純平均★★
 *   （2026-09-30 指示書AQ 決定4 / §5-1・2026-10-02 D-155）—
 *   ★以前は面積加重（Σclean_area ÷ Σtask_area）だった★。レポート画面・F-08 と同じ
 *   ★`meanCleanRatio()`（`lib/judge/kpi.ts`）で畳む★。★面積加重にしないこと★。判定が変わるケースは
 *   `__tests__/rescue.test.ts` の表6。★この関数は面積を読まず、渡された `completion_rate` だけを使う★。
 *
 * ★★4つ目の条件（出発失敗を未実施と別に数える）は落としてある★★ —
 *   実開始時刻がDBに無く、猶予30分の計算をする材料が無いため（指示書AI §1-1）。
 *   ★TeamWorks から所在の回答が来たら足す★（第9回 11章で照会中）。
 *
 * ★★このファイルは日本語を1文字も作らない★★（D-111）—
 *   理由は識別子（`RescueReason`）で返し、文言は画面側の `t()` が作る。
 *   ★判定層で和文を組むと EN 切替で日本語が残る★。
 *
 * ★★連続日数をここで数え直さないこと★★（指示書AI §13-6 / D-109 の再発防止）—
 *   R-2 の `consecutive_problem_days` は ★引数で受け取る★。
 *   算出の責務は `computeConsecutiveProblemDays()`（`lib/db/clean-log-mapping.ts`）の1本にある。
 */

/** 要レスキューの理由（★複数該当したら全部入る★） */
export type RescueReason = "not_executed_streak" | "escalation_l3" | "completion_drop";

export interface RescueJudgement {
  /** 3条件のいずれかに当たったか */
  required: boolean;
  /** 当たった理由（★空配列なら required は false★） */
  reasons: RescueReason[];
  /** 表示用の付帯情報。★判定には使わない★ */
  not_executed_days: number;
  consecutive_problem_days: number;
  /** ★1回ごとの完了率の単純平均★（`meanCleanRatio()`）。未実施・データなしは null */
  completion_rate: number | null;
  previous_completion_rate: number | null;
  /** 低下の比（0.25 = 25%低下）。算出できないときは null */
  drop_ratio: number | null;
}

/**
 * 判定に必要な1行（＝1清掃エリアの1回の清掃）。
 *
 * ★`ConsecutiveInput`（`lib/db/clean-log-mapping.ts`）がそのまま渡せる形にしてある★ —
 *   ★★`lib/judge/` から `lib/db/` を import しないため★★（判定層はDBの都合を知らない）。
 *   詰め替えは不要で、`ConsecutiveInput[]` を直接渡せる（余分なキーは無視される）。
 *
 * ★★面積（`clean_area` / `task_area`）はもう受け取らない★★（2026-09-30。指示書AQ 決定4）—
 *   `ConsecutiveInput` には付いたまま届くが、★判定では読まない★。
 *   ★型から外したのは「面積で重み付けし直す」実装を型で書けなくするため★。
 */
export interface RescueRowInput {
  /** ISO8601（+09:00）。★先頭10文字を JST の日付として使う★ */
  scheduled_at: string;
  /**
   * 1回ごとの完了率（★`runCompletionRate()` の戻り★。D-155）。丸める前の生値。
   * ★未実施は `null`★（`task_area = 0` は 0。P-5=B）。★R-3 は値そのもの（その日の単純平均）を読む★。
   * ★R-1 はこれを読まない★（`departed` を読む）。
   */
  completion_rate: number | null;
  /**
   * ★出発したか（＝レポートの行があるか）★。★R-1 はこれで未実施を数える★（【未決1】案A。D-156）。
   * ★必須（optional にしない）★ — 詰め忘れが `undefined`（＝全部未実施）のまま通る。
   */
  departed: boolean;
}

/**
 * 1回ごとの完了率の合計と件数だけを持つ最小の形（`facilityDayRate()` の入力）。
 * ★名前（`clean_ratio_*`）は変えていない★（指示書AS D7）— 中身は `clean_ratio` 列ではない。
 */
export interface RescueRatioLike {
  /** その日の1回ごとの完了率の合計（★`completion_rate` が null の行は足さない★） */
  clean_ratio_sum: number;
  /** 上の合計に足した行数（★`completion_rate` が null の行（未実施）は数えない★） */
  clean_ratio_count: number;
}

/** スケジュール存在日1日ぶんに畳んだ結果 */
export interface RescueDay extends RescueRatioLike {
  /** JST の `YYYY-MM-DD` */
  date: string;
  /** ★その日に未実施（`departed === false`）の行が1つでもあれば true（OR）★ */
  not_executed: boolean;
}

/** 入力（1施設ぶん） */
export interface RescueInput {
  /**
   * ★その施設の、評価日**以前**の行★（日付順でなくてよい。この関数が並べ替える）。
   *
   * ★★「評価日の行だけ」に絞って渡さないこと★★ — R-1 は3日、R-3 は前回の日を必要とする。
   * ★★評価日は「渡された行のうち最も新しいスケジュール存在日」★★ になる（指示書AI §6-3）。
   *   ★当該日に予定が無い施設も要レスキューに出す★ため、
   *   呼び出し側は「当該日以下」で切るだけでよく、当該日ちょうどで絞ってはいけない。
   */
  rows: readonly RescueRowInput[];
  /**
   * ★既存の `computeConsecutiveProblemDays()` の値をそのまま渡す★
   * （★ここで数え直さない★。D-109 / 指示書AI §13-6）。
   */
  consecutive_problem_days: number;
}

/**
 * 施設日完了率 ＝ その日の1回ごとの完了率の単純平均（★清掃1回 = 1票★。指示書AQ 決定4 / §5-1・D-155）
 *
 * ★★面積加重にしないこと★★（2026-09-30 に面積加重から変更）— 理由は `meanCleanRatio()`
 *   （`lib/judge/kpi.ts`）の docstring。★レポート画面・F-08 と畳み方を揃える★ため、
 *   ★★自前で割り算を書かず `meanCleanRatio()` に任せる★★（分母0で `null` を返す規則もそちらが持つ。
 *   ここで書き直すと ★∞ や NaN が漏れる★）。
 *
 * @returns 丸める前の生値。★件数が0（＝全行が未実施・行なし）は `null`★（0% ではない）
 */
export function facilityDayRate(rows: readonly RescueRatioLike[]): number | null {
  let cleanRatioSum = 0;
  let cleanRatioCount = 0;
  for (const row of rows) {
    cleanRatioSum += row.clean_ratio_sum;
    cleanRatioCount += row.clean_ratio_count;
  }
  return meanCleanRatio({ cleanRatioSum, cleanRatioCount });
}

/**
 * 同一日の複数行を1日に畳む（★OR。D-109 A案★）。
 *
 * ★施設に複数の清掃エリア（ロボット）があると同じ日に複数行が生まれる★ため、
 * 畳まずに数えると「N日連続」が水増しされる（`foldProblemDaysByDate()` と同じ理由）。
 * ★同一ロボットが同じ日に2回清掃した場合も1日に畳む★（決定2。D-026 は日単位であって回数単位ではない）。
 *
 * ★★未実施フラグ（`!departed`）は OR、完了率は `completion_rate` が null でない行だけの合計と件数★★ —
 *   片方の台だけ未実施の日は ★「未実施の日」であり、かつ完了率も算出できる★
 *   （もう1台の完了率が残るため）。★未実施の行を 0% として件数に入れないこと★ —
 *   R-1 で既に数えている失敗を、R-3 でも二重に数えることになる（`aggregateCleanStatsByFacility()` と同じ扱い）。★これは矛盾ではない★ — R-1 と R-3 は別の条件で、
 *   同じ日に両方立ちうる（指示書AI 表5 5-5）。
 *
 * @returns ★日付昇順・重複なし★
 */
export function foldRescueDays(rows: readonly RescueRowInput[]): RescueDay[] {
  const byDate = new Map<string, RescueDay>();
  for (const row of rows) {
    // ★ISO8601（+09:00）の先頭10文字＝ JST の日付★（`Date` に通して getter を使わない。
    //   実行環境のタイムゾーンで1日ずれる）
    const date = row.scheduled_at.slice(0, 10);
    // ★未実施は `departed` で数える★（【未決1】案A。D-156 / 指示書AU D3。完了率の規則がまた
    //   変わっても、未実施の数え方が巻き込まれないため）
    const notExecuted = !row.departed;
    // ★完了率の Σ と件数は null を数えない★（未実施の清掃）
    const ratio = row.completion_rate ?? 0;
    const counted = row.completion_rate === null ? 0 : 1;
    const current = byDate.get(date);
    if (current) {
      current.not_executed = current.not_executed || notExecuted;
      current.clean_ratio_sum += ratio;
      current.clean_ratio_count += counted;
    } else {
      byDate.set(date, {
        date,
        not_executed: notExecuted,
        clean_ratio_sum: ratio,
        clean_ratio_count: counted,
      });
    }
  }
  return [...byDate.values()].sort((a, b) =>
    a.date < b.date ? -1 : a.date > b.date ? 1 : 0
  );
}

/**
 * R-1 — 直近から遡って、未実施が何日続いているかを数える。
 *
 * ★「連続」はカレンダー日ではなく「スケジュール存在日の連続」★（D-026 / `CLAUDE.md` §4.5）—
 *   ここでは ★渡された `RescueDay` がスケジュール存在日そのもの★なので、
 *   清掃予定のない日は自然に飛ばされ、連続が途切れない。
 *
 * ★★最新日が未実施でなければ 0★★（指示書AI 表1 1-7。過去に3日連続があっても拾わない）。
 *
 * @param days ★日付昇順★の畳み込み済みの日（`foldRescueDays()` の戻り値）
 */
export function countNotExecutedStreak(days: readonly RescueDay[]): number {
  let count = 0;
  for (let i = days.length - 1; i >= 0; i -= 1) {
    if (!days[i].not_executed) break;
    count += 1;
  }
  return count;
}

/**
 * R-2 — 連続問題レベルが L3 か。
 *
 * ★★既存の `judgeEscalationLevel()` をそのまま使う★★（指示書AI 表2 / §13-6）—
 *   ★`>= 3` と書き直さないこと★。閾値が `ESCALATION_THRESHOLDS` から読まれなくなり、
 *   L3 の定義が2箇所に分かれる。
 */
export function isEscalationL3(consecutiveProblemDays: number): boolean {
  return judgeEscalationLevel(consecutiveProblemDays) === "L3";
}

/**
 * R-3 — 完了率が前回比で急落したか。
 *
 * ```
 * (前回の完了率 − 当日の完了率) ÷ 前回の完了率 ≧ COMPLETION_DROP.dropRatio
 * ```
 *
 * ★★比（％）であって差（ポイント）ではない★★（ユーザー確定 2026-09-15「比で」）—
 *   前日30% → 当日18% は「12ポイント低下」だが「40%低下」。★ユーザーは後者を拾う側を選んだ★。
 *
 * ★★算出しない場合（指示書AI 決定B〜D）★★:
 *   - 前回が `null`（未実施・前回の日が無い）… 比べる相手が無い（決定C）
 *   - 当日が `null`（未実施）… 同上。★未実施を 0% とみなさない★（決定D / §13-5）
 *   - 前回が `0`… 0で割れないし、下がりようもない（決定B）
 *
 * ★上昇したときは `dropped: false`★（決定E。「急落」なので低下方向のみ。絶対値で見ない）。
 *   ★ただし `drop_ratio` は算出できる限り返す★（負値＝上昇）— 表示の付帯情報であり、
 *   ★「算出できない」（`null`）と「低下していない」（負値・小さい値）は別の事実★だから。
 *
 * ★閾値 `dropRatio` は `COMPLETION_DROP`（`lib/config`）から読む★（ハードコード禁止。`CLAUDE.md` §7）。
 *
 * @param previous 前回のスケジュール存在日の完了率（`facilityDayRate()`。★カレンダー前日ではない★。決定A）
 * @param current  当日（評価日）の完了率（`facilityDayRate()`）
 */
export function judgeCompletionDrop(
  previous: number | null,
  current: number | null
): { dropped: boolean; drop_ratio: number | null } {
  if (previous === null || current === null || previous === 0) {
    return { dropped: false, drop_ratio: null };
  }
  const dropRatio = (previous - current) / previous;
  return { dropped: dropRatio >= COMPLETION_DROP.dropRatio, drop_ratio: dropRatio };
}

/**
 * 3条件を合成して、要レスキューかどうかと ★その理由を全部★ 返す。
 *
 * ★★理由を1つに丸めないこと★★（指示書AI 表5 5-5）—
 *   「なぜレスキューが要るのか」が運用担当の判断材料そのもので、
 *   ★丸めると現場に何を伝えるかが決まらない★。
 *
 * ★評価日は「渡された行のうち最も新しいスケジュール存在日」★（`RescueInput.rows` の docstring）。
 *   ★評価日そのものが要るなら `foldRescueDays(rows)` の末尾を見ること★
 *   （★呼び出し側で日付の畳み込みを書き直さない★）。
 */
export function judgeRescue(input: RescueInput): RescueJudgement {
  const days = foldRescueDays(input.rows);
  const today = days.at(-1);
  const previous = days.at(-2);

  const notExecutedDays = countNotExecutedStreak(days);
  const currentRate = today ? facilityDayRate([today]) : null;
  const previousRate = previous ? facilityDayRate([previous]) : null;
  const drop = judgeCompletionDrop(previousRate, currentRate);

  const reasons: RescueReason[] = [];
  // ★順序は R-1 → R-2 → R-3 に固定する★（毎朝見る画面で理由の並びが揺れないため）
  if (notExecutedDays >= RESCUE_THRESHOLDS.notExecutedDays) {
    reasons.push("not_executed_streak");
  }
  if (isEscalationL3(input.consecutive_problem_days)) reasons.push("escalation_l3");
  if (drop.dropped) reasons.push("completion_drop");

  return {
    required: reasons.length > 0,
    reasons,
    not_executed_days: notExecutedDays,
    consecutive_problem_days: input.consecutive_problem_days,
    // ★丸めない生値を返す★（`CLAUDE.md` §9.3「数値の丸めは表示層でのみ行う」）
    completion_rate: currentRate,
    previous_completion_rate: previousRate,
    drop_ratio: drop.drop_ratio,
  };
}
