/**
 * 予定（清掃タイムテーブル）を ★`clean_timetables` と `clean_timetable_histories` の両方から★ 読む共通の読み口
 * （指示書AU 段階3b。TeamWorks 第11回 P-7=C。D-158 項4）。
 *
 * ------------------------------------------------------------------------
 * ★★規則（ここ1箇所にしか書かない）★★
 * ------------------------------------------------------------------------
 * 1. ★同じ `args` を2表の `findMany` に渡す★（`where` を2回書かない。片方だけ直すと静かにずれる）
 * 2. ★同じ `id` は1件として扱い、`clean_timetables`（本表）側を採る★
 *    — ★履歴の `id` は本表の `id` のコピー★（ユーザー回答 2026-10-05。§3b-7 Q1）なので id で重複を除けば足りる
 * 3. ★「本表が勝つ」を厳密に守るための影チェック★: 履歴だけに出た id を本表で引き直し、
 *    ★本表に行があればその履歴の行は捨てる★。
 *    理由: 同じ id で `start_at` や `precheck` が2表で違うと、本表の行は `where` から外れて履歴の行だけが当たる。
 *    影チェックが無いと ★負けるはずの履歴の行が出る★（T5）。★履歴だけの id が0件ならクエリを撃たない★（T6）
 * 4. ★同じ id で `facility_id` / `section_id` / `start_at` のどれかが違ったら `db.timetable_conflict` の warn★
 *    （§3b-7 Q2。採るのは本表のまま）。比べるのは ★両方の行に載っている列だけ★（`select: { id }` なら比べない）
 * 5. ★期間の基準は `start_at` のまま★（Q3。`CLAUDE.md` §4.7）。★`inactive` で絞らない★（履歴に無い列。
 *    `where` に書くと型エラーになる — それでよい）
 *
 * ★実績は `clean_task_reports` だけのまま★（D-151）。この読み口は予定の表だけを扱う。
 *
 * ------------------------------------------------------------------------
 * ★★やってはいけないこと★★
 * ------------------------------------------------------------------------
 * - ★`count` / `groupBy` を2表にまたがって書かない★（同じ id の重複を引けない）。
 *   要るなら `findTimetables()` で id を取ってから数える（2026-10-05 時点で該当0件）
 * - ★`take` / `skip` / `cursor` / `distinct` を渡さない★（2表を合わせた後の件数・順序と食い違う。型で禁止してある）
 * - ★`orderBy` は渡してよいが、合わせた後の順序は保証されない★（呼び出し側が並べ直すこと）
 *
 * ------------------------------------------------------------------------
 * ★★性能（3b-4）★★
 * ------------------------------------------------------------------------
 * 往復は1本 → 2本（＋履歴だけの id があれば影チェック）。2表は `Promise.all` で並べる。
 * 両表とも `start_at` / `facility_id` / `parent_id` に索引がある（`prisma/schema.prisma`）。
 * ★古い予定が履歴へ移るなら行の大半（保持2年）は履歴側★で、★期間無指定の経路は履歴も全期間読む★
 * （`cleanLogs()` の日付なし・`CONSECUTIVE_RANGE_UNBOUNDED`・総清掃面積の期間なし）。
 * 今と同じ性質なので新たに絞らないが、★`judge.*` ログに出る行数は履歴のぶん増える★。
 * `IN` の分割は既存の `chunkForInClause()`（上限 `DB_IN_CLAUSE_MAX_ITEMS`）。
 *
 * ★生SQL は使わない★（`CLAUDE.md` §3）。すべて Prisma のクエリビルダ。
 */
import { chunkForInClause } from "@/lib/db/chunked";
import { jstIsoFromDb } from "@/lib/format/date";
import type { Prisma } from "@/lib/generated/prisma/client";
import { logEvent } from "@/lib/logger";
import type { DbTimetableConflictLogEvent, TimetableConflictColumn } from "@/lib/logger/events";

/**
 * 2表に渡す `findMany` の引数。
 *
 * ★型は履歴側（`CleanTimetableHistoryFindManyArgs`）で取る★ — 履歴の列は本表の列の部分集合なので、
 *   ★履歴に通る引数は本表にも通る★（逆は `inactive` / `updated_at` で通らない＝書けば型エラーになる）。
 * ★`select` を書くなら `id: true` を必ず含む★（重複を id で除くため。型で強制する）。
 *   `select` なし（`include` のみ／全列）なら id は必ず載る。
 * ★`take` / `skip` / `cursor` / `distinct` / `omit` は渡せない★（ファイル冒頭）。
 */
export type TimetableFindManyArgs = Omit<
  Prisma.CleanTimetableHistoryFindManyArgs,
  "select" | "take" | "skip" | "cursor" | "distinct" | "omit"
> & {
  // ★`Omit` だけでは総称の引数で余分なキーが通ってしまうので、`never` で明示的に塞ぐ★
  take?: never;
  skip?: never;
  cursor?: never;
  distinct?: never;
  omit?: never;
} & (
    | { select: Prisma.CleanTimetableHistorySelect & { id: true }; include?: undefined }
    | { select?: undefined }
  );

/**
 * `findTimetables(prisma, args)` が返す1行の型（★履歴側のモデルで決める★。本表だけの列は載らない）。
 * `& { id: bigint }` は「`id` が必ず載る」（`TimetableFindManyArgs` で強制済み）を総称のまま型に示すため。
 */
export type TimetableRow<A extends TimetableFindManyArgs> = Prisma.CleanTimetableHistoryGetPayload<A> & {
  id: bigint;
};

/**
 * この読み口が必要とする Prisma の最小限の形（★テストからスタブを渡せるようにするため★）。
 *
 * ★本表の `findMany` は影チェック（`{ where: { id: { in } }, select }`）も受ける★。
 * 戻りは `unknown`（型は引数から `TimetableRow<A>` で決める。生成済みクライアントの総称メソッドを
 * そのまま受けられるよう、ここでは広く取る）。
 */
export interface TimetableSourcePrisma {
  cleanTimetable: { findMany(args: object): Promise<readonly unknown[]> };
  cleanTimetableHistory: { findMany(args: object): Promise<readonly unknown[]> };
}

/** `findTimetableById()` が必要とする Prisma の最小限の形 */
export interface TimetableByIdPrisma {
  cleanTimetable: { findUnique(args: object): Promise<unknown> };
  cleanTimetableHistory: { findUnique(args: object): Promise<unknown> };
}

/** 2表で中身を比べる列（§3b-7 Q1/Q2）。★並び順が warn の `columns` の順になる★ */
const CONFLICT_COLUMNS: readonly TimetableConflictColumn[] = ["facility_id", "section_id", "start_at"];

/** 影チェックで本表から読む列（★比べる列も読む★ — 本表が期間外のときも Q2 の warn を出すため） */
const SHADOW_SELECT = { id: true, facility_id: true, section_id: true, start_at: true } as const;

interface ShadowRow {
  id: bigint;
  facility_id: bigint;
  section_id: bigint;
  start_at: Date;
}

/**
 * 履歴の行を id で引けるようにする。★同じ id が2回出たら例外★（履歴の主キーは `id` なので起きない。
 * 起きたら呼び出しの誤り — 例えば同じ id が2つの塊に入る `where` を書いた）。
 */
function indexHistoryRows<T extends { id: bigint }>(historyRows: readonly T[]): Map<string, T> {
  const byId = new Map<string, T>();
  for (const row of historyRows) {
    const key = row.id.toString();
    if (byId.has(key)) {
      throw new Error(
        `[db] timetable-source: clean_timetable_histories の同じ id=${key} が2回返りました（呼び出しの誤り）`
      );
    }
    byId.set(key, row);
  }
  return byId;
}

/**
 * ★純粋関数★ — 本表の行と履歴の行を合わせる（§3b-2 の 1）。
 *
 * - `mainRows` を全部採る
 * - `historyRows` は ★`id.toString()` が `mainRows` にも `shadowedIds` にも無いものだけ★ 足す
 * - ★`historyRows` の中で同じ id が2回出たら例外★
 * - ★並び順は保証しない★（本表の行 → 履歴だけの行の順に並ぶが、呼び出し側はこれに頼らないこと）
 *
 * @param shadowedIds 影チェックで本表に行があった id（★`where` から外れた本表の行が勝つ★）
 */
export function mergeTimetableRows<T extends { id: bigint }>(
  mainRows: readonly T[],
  historyRows: readonly T[],
  shadowedIds: ReadonlySet<string>
): T[] {
  const historyById = indexHistoryRows(historyRows);
  const mainIds = new Set(mainRows.map((row) => row.id.toString()));
  const merged = [...mainRows];
  for (const [key, row] of historyById) {
    if (mainIds.has(key) || shadowedIds.has(key)) continue;
    merged.push(row);
  }
  return merged;
}

/** 列の値を warn に出す文字列にする（id は文字列、日時は `jstIsoFromDb()`） */
function conflictValue(value: unknown): string {
  return value instanceof Date ? jstIsoFromDb(value) : String(value);
}

function sameValue(a: unknown, b: unknown): boolean {
  if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
  return String(a) === String(b);
}

/**
 * 同じ id の本表の行と履歴の行を比べ、違えば `db.timetable_conflict` の warn を出す（§3b-7 Q2）。
 * ★両方の行に載っている列だけ★比べる。★採るのは本表★（この関数は出すだけで、行は選ばない）。
 */
function reportConflict(main: Record<string, unknown>, history: Record<string, unknown>): void {
  const columns = CONFLICT_COLUMNS.filter(
    (col) => col in main && col in history && !sameValue(main[col], history[col])
  );
  if (columns.length === 0) return;
  const event: DbTimetableConflictLogEvent = {
    event: "db.timetable_conflict",
    timetable_id: String(main.id),
    adopted_table: "clean_timetables",
    columns,
  };
  for (const col of columns) {
    event[`main_${col}`] = conflictValue(main[col]);
    event[`history_${col}`] = conflictValue(history[col]);
  }
  logEvent("warn", event);
}

/**
 * 2表から引いた行を合わせる（影チェック・Q2 の warn・重複除去）。
 * `findTimetables()` と `findTimetablesByChunks()` が共有する（★規則を1箇所にする★）。
 */
async function resolveTimetableRows<T extends { id: bigint }>(
  prisma: TimetableSourcePrisma,
  mainRows: readonly T[],
  historyRows: readonly T[]
): Promise<T[]> {
  const historyById = indexHistoryRows(historyRows);
  const mainById = new Map(mainRows.map((row) => [row.id.toString(), row]));

  // ★両方に当たった id は中身を比べる（Q2）★
  for (const [key, history] of historyById) {
    const main = mainById.get(key);
    if (main) reportConflict(main, history);
  }

  // ★影チェック★ — 履歴だけに出た id を本表で引き直す。★0件ならクエリを撃たない★
  const historyOnly = [...historyById.values()].filter((row) => !mainById.has(row.id.toString()));
  const shadowChunks = await Promise.all(
    chunkForInClause(historyOnly.map((row) => row.id)).map(
      async (ids) =>
        (await prisma.cleanTimetable.findMany({
          where: { id: { in: ids } },
          select: SHADOW_SELECT,
        })) as ShadowRow[]
    )
  );
  const shadowedIds = new Set<string>();
  for (const shadow of shadowChunks.flat()) {
    const key = shadow.id.toString();
    shadowedIds.add(key);
    const history = historyById.get(key);
    if (history) reportConflict({ ...shadow }, history);
  }

  return mergeTimetableRows(mainRows, historyRows, shadowedIds);
}

/**
 * 予定を2表から読む（§3b-2 の 2）。★同じ `args` を `cleanTimetable.findMany` と
 * `cleanTimetableHistory.findMany` に `Promise.all` で渡す★。
 *
 * @param args ★`where` の中身は今までどおり★（`start_at` で期間・`precheck` で本清掃）。`inactive` は書かない
 * @returns 合わせた行。★並び順は保証しない★（`orderBy` を渡しても2表を合わせた後は写しにもならない）
 */
export async function findTimetables<A extends TimetableFindManyArgs>(
  prisma: TimetableSourcePrisma,
  args: A
): Promise<TimetableRow<A>[]> {
  const [mainRows, historyRows] = await Promise.all([
    prisma.cleanTimetable.findMany(args),
    prisma.cleanTimetableHistory.findMany(args),
  ]);
  return resolveTimetableRows(
    prisma,
    mainRows as TimetableRow<A>[],
    historyRows as TimetableRow<A>[]
  );
}

/**
 * `IN` を塊に分けて引く経路用（§3b-3。`parent_id IN` / `id IN`）。
 *
 * ★全塊を2表とも引いてから、1回だけ `mergeTimetableRows()` を通す★ —
 *   2表で `parent_id` が違う同じ id は別の塊に入りうる（T8）ので、塊の中の重複落としだけでは足りない。
 * ★塊ごとに `buildArgs()` を1回だけ呼び、その同じ引数を2表に渡す★。★塊が0個ならクエリを撃たない★。
 */
export async function findTimetablesByChunks<C, A extends TimetableFindManyArgs>(
  prisma: TimetableSourcePrisma,
  chunks: readonly C[],
  buildArgs: (chunk: C) => A
): Promise<TimetableRow<A>[]> {
  if (chunks.length === 0) return [];
  const results = await Promise.all(
    chunks.map((chunk) => {
      const args = buildArgs(chunk);
      return Promise.all([
        prisma.cleanTimetable.findMany(args),
        prisma.cleanTimetableHistory.findMany(args),
      ]);
    })
  );
  return resolveTimetableRows(
    prisma,
    results.flatMap(([main]) => main) as TimetableRow<A>[],
    results.flatMap(([, history]) => history) as TimetableRow<A>[]
  );
}

/**
 * 予定を1件、id で引く（`precheckItemById()` 用。§3b-2 の 5）。
 *
 * ★本表を `findUnique` → 無ければ履歴を `findUnique`★（本表が勝つ）。同じ `args` を両方に渡す。
 * ★本表で見つかったら履歴は引かない★（往復は本表にあるとき1本・無いとき2本）。
 *
 * @param select ★`id: true` を必ず含む★（`findTimetables()` と同じ作法）
 */
export async function findTimetableById<S extends Prisma.CleanTimetableHistorySelect & { id: true }>(
  prisma: TimetableByIdPrisma,
  id: bigint,
  select: S
): Promise<Prisma.CleanTimetableHistoryGetPayload<{ select: S }> | null> {
  const args = { where: { id }, select };
  const main = await prisma.cleanTimetable.findUnique(args);
  if (main) return main as Prisma.CleanTimetableHistoryGetPayload<{ select: S }>;
  const history = await prisma.cleanTimetableHistory.findUnique(args);
  return (history ?? null) as Prisma.CleanTimetableHistoryGetPayload<{ select: S }> | null;
}
