import { describe, expect, it } from "vitest";
import { todayJst } from "@/lib/format/date";
import {
  clampWeek,
  isCurrentPeriod,
  isPastPeriod,
  periodLabel,
  periodRange,
  prevPeriodLabel,
  shiftPeriod,
  weekOfMonth,
} from "@/lib/format/period";

/**
 * 期間ヘルパーの期待値表テスト（CLAUDE.md §9.4「テストを先に書く」）
 *
 * ★週は日曜始まり（CLAUDE.md §4.7）★
 * ★月跨ぎ週は「月境界で分割する」（D-071。TeamWorks のサマリ集計に合わせた）★
 *   週は日曜〜土曜だが、月をまたぐ週は1日で切り、それぞれの月の別々の週として数える。
 *   各月の第1週は必ず1日から始まり、最終週は必ず末日で終わる。
 *   ★週の長さは1〜7日で変わり、月の週番号は1〜6（第6週は発生する）★
 *
 *   旧ルール（D-067「1日を含む週がその月の第1週」）は破棄。
 *   同じ物理週を2つの月に分けない方式だったため、TeamWorks の週次サマリと帰属がズレていた。
 *
 * ★`week` は `number`。月次は 0 で表す（D-072。段階5で `number | null` から変更）★
 *   サマリ4テーブルの `week` が NOT NULL で「月次は 0」なので、内部表現も 0 に揃えた。
 *   `weekly` で `week < 1` は不正（`periodRange` は null / `periodLabel` は月だけのラベル）。
 *   ★`periodLabel` が「第0週」と出さないことをこのファイルで固定している★
 *
 * 2026年の曜日（このテストの前提。Date.UTC で検証済み）:
 *   2026-02-01 日 / 2026-03-01 日 / 2026-05-01 金 / 2026-08-01 土 / 2026-09-01 火 /
 *   2026-12-01 火 / 2026-01-01 木 / 2027-01-01 金
 */

describe("weekOfMonth（月跨ぎ週の帰属。★D-071★）", () => {
  // ★期待値表1（指示書H）。2020〜2040 の全252か月を総当たりして検証済みの表★
  // | 入力 | 曜日 | 期待 year/month/week | 何を固定するか |
  const table: Array<[string, string, number, number, number, string]> = [
    // 1日が土曜の31日月（2026年8月）。★第1週が1日しかなく、第6週が発生する★
    ["2026-08-01", "土", 2026, 8, 1, "★1日が土曜 → 第1週は1日だけ★"],
    ["2026-08-02", "日", 2026, 8, 2, "日曜 → 第2週の初日"],
    ["2026-08-29", "土", 2026, 8, 5, "土曜 → 第5週の最終日"],
    ["2026-08-30", "日", 2026, 8, 6, "★日曜だが8月に残る → 8月第6週（旧D-067なら9月第1週）★"],
    ["2026-08-31", "月", 2026, 8, 6, "★月末 → 8月第6週★"],

    // 月境界で切れる週（2026-08-30〜09-05 が2つの期間に分かれる）
    ["2026-09-01", "火", 2026, 9, 1, "★火曜だが月初 → 9月第1週の初日★"],
    ["2026-09-05", "土", 2026, 9, 1, "★土曜 → 9月第1週の最終日（5日間）★"],
    ["2026-09-06", "日", 2026, 9, 2, "★日曜 → 9月第2週★"],
    ["2026-09-12", "土", 2026, 9, 2, "★土曜 → 9月第2週★"],
    ["2026-09-30", "水", 2026, 9, 5, "月末が水曜 → 第5週（4日間）"],

    // 7月（1日が水曜の31日月）は5週
    ["2026-07-26", "日", 2026, 7, 5, "★7月に残る（旧D-067では「8月第1週」だった）★"],
    ["2026-07-25", "土", 2026, 7, 4, "第4週の最終日（7月は全5週）"],

    // 1日が金曜の31日月（2026年5月）。この月も第6週が発生する
    ["2026-05-01", "金", 2026, 5, 1, "1日が金曜の31日月 → この月も第6週が発生する"],
    ["2026-05-31", "日", 2026, 5, 6, "★日曜だが月末 → 5月第6週（1日だけ）★"],

    // 月初が日曜の月（2026年2月・3月）
    ["2026-02-01", "日", 2026, 2, 1, "1日が日曜の月 → 第1週が7日ちょうど"],
    ["2026-02-28", "土", 2026, 2, 4, "28日・日曜始まりはちょうど4週"],
    ["2026-03-01", "日", 2026, 3, 1, "月初が日曜"],

    // 年跨ぎ週も月境界で切れる
    ["2026-12-26", "土", 2026, 12, 4, "第4週の最終日（12月は全5週）"],
    ["2026-12-27", "日", 2026, 12, 5, "★12月に残る（旧D-067では「2027年1月第1週」だった）★"],
    ["2026-12-31", "木", 2026, 12, 5, "★年末日も12月第5週（旧D-067では「2027年1月第1週」）★"],
    ["2027-01-01", "金", 2027, 1, 1, "年初"],
    ["2025-12-28", "日", 2025, 12, 5, "★2025年12月に残る（旧D-067では「2026年1月第1週」だった）★"],
    ["2026-01-03", "土", 2026, 1, 1, "同じ週の最終日"],
    ["2026-01-01", "木", 2026, 1, 1, "年初。1日が木曜"],
  ];

  for (const [input, dow, year, month, week, intent] of table) {
    it(`${input}(${dow}) → ${year}年${month}月 第${week}週（${intent}）`, () => {
      expect(weekOfMonth(input)).toEqual({ year, month, week });
    });
  }

  /**
   * ★H-3: 同じ物理週が2つの期間に分かれること★
   *
   * 2026-08-30(日)〜09-05(土) は暦の上では1つの週だが、D-071 では
   * 08-30〜08-31 が8月第6週、09-01〜09-05 が9月第1週になる。
   * 旧ルール（D-067）ではどちらも「9月第1週」だった。
   */
  it("★H-3★ 同じ物理週でも月境界で別の期間になる（旧D-067では同じ9月第1週だった）", () => {
    const before = weekOfMonth("2026-08-30");
    const after = weekOfMonth("2026-09-01");
    expect(before).toEqual({ year: 2026, month: 8, week: 6 });
    expect(after).toEqual({ year: 2026, month: 9, week: 1 });
    expect(before).not.toEqual(after);
  });

  /**
   * ★期待値表5: 不変条件（2020〜2040 を総当たり）★
   *
   * D-071 では「1日が金曜または土曜の30日/31日月」で第6週が発生する。
   * 252か月中55か月（最初の3件は 2020-05 / 2020-08 / 2021-01）。
   * 2026年は5月（1日が金曜の31日月）と8月（1日が土曜の31日月）。
   *
   * 旧ルール（D-067）では「週末（土）がその月にある週」だけを数えていたため
   * 最大5だったが、月境界で切る D-071 では月にかかる週をすべて数えるので6まで出る。
   */
  it("★D-071 では週番号は最大6。第6週は発生する★（2020〜2040を総当たり）", () => {
    const found = new Set<number>();
    const perMonth = new Map<string, Set<number>>();

    const cursor = new Date(Date.UTC(2020, 0, 1));
    const end = Date.UTC(2040, 11, 31);
    while (cursor.getTime() <= end) {
      const date = cursor.toISOString().slice(0, 10);
      const { year, month, week } = weekOfMonth(date);
      found.add(week);
      const key = `${year}-${month}`;
      if (!perMonth.has(key)) perMonth.set(key, new Set());
      perMonth.get(key)?.add(week);
      cursor.setUTCDate(cursor.getUTCDate() + 1);
    }

    expect([...found].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);

    // 各月の週番号は 1..N の連番（欠番がない）
    for (const [key, weeks] of perMonth) {
      const sorted = [...weeks].sort((a, b) => a - b);
      expect(sorted, key).toEqual(
        Array.from({ length: sorted.length }, (_, i) => i + 1)
      );
    }

    // 第6週が発生する月は252か月中55か月
    const sixWeekMonths = [...perMonth.entries()]
      .filter(([, weeks]) => weeks.has(6))
      .map(([key]) => key);
    expect(perMonth.size).toBe(252);
    expect(sixWeekMonths).toHaveLength(55);
    expect(sixWeekMonths.slice(0, 3)).toEqual(["2020-5", "2020-8", "2021-1"]);
    // 2026年で第6週が発生するのは5月と8月だけ
    expect(sixWeekMonths.filter((key) => key.startsWith("2026-"))).toEqual([
      "2026-5",
      "2026-8",
    ]);
  });

  it("2026年8月（1日が土曜の31日月）は★6週ある★", () => {
    expect(weekOfMonth("2026-08-29")).toEqual({ year: 2026, month: 8, week: 5 });
    expect(weekOfMonth("2026-08-31")).toEqual({ year: 2026, month: 8, week: 6 });
    // 第6週は存在する。第7週は存在しない
    expect(periodRange("weekly", 2026, 8, 6)).not.toBeNull();
    expect(periodRange("weekly", 2026, 8, 7)).toBeNull();
  });
});

describe("periodRange（期間の [開始日, 終了日]。両端含む）", () => {
  // ★月次の week は 0（D-072）。null ではない★
  it("monthly は暦月で締める", () => {
    expect(periodRange("monthly", 2026, 9, 0)).toEqual({
      from: "2026-09-01",
      to: "2026-09-30",
    });
    expect(periodRange("monthly", 2026, 12, 0)).toEqual({
      from: "2026-12-01",
      to: "2026-12-31",
    });
    expect(periodRange("monthly", 2026, 2, 0)).toEqual({
      from: "2026-02-01",
      to: "2026-02-28",
    });
    // 閏年
    expect(periodRange("monthly", 2028, 2, 0)).toEqual({
      from: "2028-02-01",
      to: "2028-02-29",
    });
  });

  it("monthly は week を無視する（YAML の Week の定義どおり）", () => {
    expect(periodRange("monthly", 2026, 9, 3)).toEqual({
      from: "2026-09-01",
      to: "2026-09-30",
    });
  });

  // ★期待値表2（指示書H）: 2026年8月・9月の全週。週の長さが1〜7日で変わる★
  // | year | month | week | from | to | 日数 | 備考 |
  const weeklyTable: Array<[number, number, number, string, string, number, string]> = [
    [2026, 8, 1, "2026-08-01", "2026-08-01", 1, "★1日だけの週★"],
    [2026, 8, 2, "2026-08-02", "2026-08-08", 7, ""],
    [2026, 8, 3, "2026-08-09", "2026-08-15", 7, ""],
    [2026, 8, 4, "2026-08-16", "2026-08-22", 7, ""],
    [2026, 8, 5, "2026-08-23", "2026-08-29", 7, ""],
    [2026, 8, 6, "2026-08-30", "2026-08-31", 2, "★月境界で切れる★"],
    [2026, 9, 1, "2026-09-01", "2026-09-05", 5, "★月境界で切れる★"],
    [2026, 9, 2, "2026-09-06", "2026-09-12", 7, ""],
    [2026, 9, 3, "2026-09-13", "2026-09-19", 7, ""],
    [2026, 9, 4, "2026-09-20", "2026-09-26", 7, ""],
    [2026, 9, 5, "2026-09-27", "2026-09-30", 4, "★月境界で切れる★"],
  ];

  for (const [year, month, week, from, to, days, note] of weeklyTable) {
    it(`weekly ${year}年${month}月 第${week}週 = ${from}〜${to}（${days}日）${note}`, () => {
      const range = periodRange("weekly", year, month, week);
      expect(range).toEqual({ from, to });
      if (!range) throw new Error("range");
      // 日数（週が7日固定でないことを固定する）
      const fromDay = Number(range.from.slice(8));
      const toDay = Number(range.to.slice(8));
      expect(toDay - fromDay + 1).toBe(days);
    });
  }

  it("weekly の年跨ぎも月境界で切る（2027年1月 第1週は元日から）", () => {
    // 旧D-067では 2026-12-27〜2027-01-02 だった
    expect(periodRange("weekly", 2027, 1, 1)).toEqual({
      from: "2027-01-01",
      to: "2027-01-02",
    });
    expect(periodRange("weekly", 2026, 12, 5)).toEqual({
      from: "2026-12-27",
      to: "2026-12-31",
    });
  });

  it("その月に存在しない週は null（★5週しかない月の第6週など★）", () => {
    // ★旧D-067では null だったが、D-071 では存在する2件★
    expect(periodRange("weekly", 2026, 12, 5)).toEqual({
      from: "2026-12-27",
      to: "2026-12-31",
    });
    expect(periodRange("weekly", 2026, 7, 5)).toEqual({
      from: "2026-07-26",
      to: "2026-07-31",
    });
    // 存在しないのはその先
    expect(periodRange("weekly", 2026, 8, 7)).toBeNull();
    expect(periodRange("weekly", 2026, 9, 6)).toBeNull();
    expect(periodRange("weekly", 2026, 12, 6)).toBeNull();
    expect(periodRange("weekly", 2026, 7, 6)).toBeNull();
  });

  it("不正な入力は null", () => {
    // ★week=0 は「月次」を表す予約値（D-072）。weekly では不正なので null★
    expect(periodRange("weekly", 2026, 9, 0)).toBeNull();
    expect(periodRange("weekly", 2026, 9, -1)).toBeNull(); // week < 1
    expect(periodRange("weekly", 2026, 9, 1.5)).toBeNull();
    expect(periodRange("weekly", 2026, 13, 1)).toBeNull(); // month 範囲外
    expect(periodRange("monthly", 2026, 0, 0)).toBeNull();
    expect(periodRange("monthly", 2026, 13, 0)).toBeNull();
    expect(periodRange("monthly", 2026.5, 9, 0)).toBeNull();
  });

  it("periodRange と weekOfMonth は往復する", () => {
    for (let month = 1; month <= 12; month++) {
      for (let week = 1; week <= 6; week++) {
        const range = periodRange("weekly", 2026, month, week);
        if (!range) continue;
        expect(weekOfMonth(range.from)).toEqual({ year: 2026, month, week });
        expect(weekOfMonth(range.to)).toEqual({ year: 2026, month, week });
      }
    }
  });

  it("★H-1★ 週の長さが1日になるケース（2026年8月 第1週は 08-01 だけ）", () => {
    const range = periodRange("weekly", 2026, 8, 1);
    expect(range).toEqual({ from: "2026-08-01", to: "2026-08-01" });
    expect(range?.from).toBe(range?.to);
  });

  it("★H-2★ 月境界で切れるケース（2026年8月 第6週の to は 08-31。09-05 ではない）", () => {
    const range = periodRange("weekly", 2026, 8, 6);
    expect(range?.to).toBe("2026-08-31");
    expect(range?.to).not.toBe("2026-09-05");
    expect(range?.from).toBe("2026-08-30");
  });

  it("★H-4★ 第6週の存在（8月は第6週がある / 9月は無い）", () => {
    expect(periodRange("weekly", 2026, 8, 6)).toEqual({
      from: "2026-08-30",
      to: "2026-08-31",
    });
    expect(periodRange("weekly", 2026, 9, 6)).toBeNull();
  });

  /**
   * ★H-5: 全日被覆（往復）。2020〜2040 を総当たり★
   *
   * 各月について periodRange の全週の日を並べると、その月の1日〜末日と一致し、
   * 各日で weekOfMonth() が元の (年,月,週) を返す。
   * 週の開始は「1日 or 日曜」、週の終了は「末日 or 土曜」。
   */
  it("★H-5★ 各月の週がその月の全日をちょうど1回ずつ覆い、往復する（2020〜2040を総当たり）", () => {
    const lengthDistribution = new Map<number, number>();
    const oneDayWeeks: string[] = [];
    let totalWeeks = 0;

    for (let year = 2020; year <= 2040; year++) {
      for (let month = 1; month <= 12; month++) {
        const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
        const covered: number[] = [];
        let week = 1;
        for (; ; week++) {
          const range = periodRange("weekly", year, month, week);
          if (!range) break;
          const key = `${year}-${String(month).padStart(2, "0")}`;
          expect(range.from.slice(0, 7), key).toBe(key);
          expect(range.to.slice(0, 7), key).toBe(key);
          const fromDay = Number(range.from.slice(8));
          const toDay = Number(range.to.slice(8));

          // 週の開始は「1日 or 日曜」/ 終了は「末日 or 土曜」
          const fromDow = new Date(
            Date.UTC(year, month - 1, fromDay)
          ).getUTCDay();
          const toDow = new Date(Date.UTC(year, month - 1, toDay)).getUTCDay();
          expect(fromDay === 1 || fromDow === 0, `${key} 第${week}週 開始`).toBe(
            true
          );
          expect(toDay === lastDay || toDow === 6, `${key} 第${week}週 終了`).toBe(
            true
          );

          const length = toDay - fromDay + 1;
          expect(length, `${key} 第${week}週 日数`).toBeGreaterThanOrEqual(1);
          expect(length, `${key} 第${week}週 日数`).toBeLessThanOrEqual(7);
          lengthDistribution.set(
            length,
            (lengthDistribution.get(length) ?? 0) + 1
          );
          if (length === 1) oneDayWeeks.push(range.from);

          for (let day = fromDay; day <= toDay; day++) {
            covered.push(day);
            const date = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
            expect(weekOfMonth(date), date).toEqual({ year, month, week });
          }
          totalWeeks++;
        }

        // 存在しない週（weeksInMonth + 1）は必ず null → 上のループが break した週番号
        expect(periodRange("weekly", year, month, week)).toBeNull();
        // 1日〜末日をちょうど1回ずつ覆う
        expect(covered, `${year}-${month}`).toEqual(
          Array.from({ length: lastDay }, (_, i) => i + 1)
        );
      }
    }

    // ★週の長さの分布（全1313週）★
    expect(totalWeeks).toBe(1313);
    expect([...lengthDistribution.entries()].sort((a, b) => a[0] - b[0])).toEqual([
      [1, 73],
      [2, 71],
      [3, 73],
      [4, 74],
      [5, 70],
      [6, 73],
      [7, 879],
    ]);
    // ★1日しかない週は73件 / 21年（年2〜3回）。2026年は 08-01 と 05-31★
    expect(oneDayWeeks).toHaveLength(73);
    expect(oneDayWeeks.filter((date) => date.startsWith("2026"))).toEqual([
      "2026-05-31",
      "2026-08-01",
    ]);
  });
});

describe("isCurrentPeriod（当期＝進行中かどうか）", () => {
  // 2026年9月 第2週 = 2026-09-06(日)〜2026-09-12(土)
  it("weekly: 初日 / 最終日は true、前日 / 翌日は false", () => {
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-06")).toBe(true); // 初日
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-09")).toBe(true); // 途中
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-12")).toBe(true); // 最終日
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-05")).toBe(false); // 前日
    expect(isCurrentPeriod("weekly", 2026, 9, 2, "2026-09-13")).toBe(false); // 翌日
  });

  it("weekly: ★月跨ぎ週は月境界で切れるので、8月の日は「9月第1週」に入らない（D-071）★", () => {
    // 旧D-067では true だった（08-30 が9月第1週の初日）
    expect(isCurrentPeriod("weekly", 2026, 9, 1, "2026-08-30")).toBe(false);
    // 08-30 / 08-31 は8月第6週（2日しかない週）に入る
    expect(isCurrentPeriod("weekly", 2026, 8, 6, "2026-08-30")).toBe(true);
    expect(isCurrentPeriod("weekly", 2026, 8, 6, "2026-08-31")).toBe(true);
    expect(isCurrentPeriod("weekly", 2026, 8, 6, "2026-09-01")).toBe(false);
    // 9月第1週は 09-01 から
    expect(isCurrentPeriod("weekly", 2026, 9, 1, "2026-09-01")).toBe(true);
    expect(isCurrentPeriod("weekly", 2026, 9, 1, "2026-09-05")).toBe(true);
    expect(isCurrentPeriod("weekly", 2026, 8, 5, "2026-08-29")).toBe(true);
  });

  // ★月次の week は 0（D-072）★
  it("monthly: 初日 / 最終日は true、前日 / 翌日は false", () => {
    expect(isCurrentPeriod("monthly", 2026, 9, 0, "2026-09-01")).toBe(true);
    expect(isCurrentPeriod("monthly", 2026, 9, 0, "2026-09-30")).toBe(true);
    expect(isCurrentPeriod("monthly", 2026, 9, 0, "2026-08-31")).toBe(false);
    expect(isCurrentPeriod("monthly", 2026, 10, 0, "2026-09-30")).toBe(false);
  });

  it("存在しない期間 / 不正な today は false", () => {
    // ★2026年12月は5週あるので、実在しない週は第6週★（旧D-067では第5週が実在しなかった）
    expect(isCurrentPeriod("weekly", 2026, 12, 6, "2026-12-28")).toBe(false);
    expect(isCurrentPeriod("weekly", 2026, 9, 6, "2026-09-30")).toBe(false);
    // ★weekly で week=0（月次の予約値）は当期にならない（D-072）★
    expect(isCurrentPeriod("weekly", 2026, 9, 0, "2026-09-07")).toBe(false);
    expect(isCurrentPeriod("monthly", 2026, 13, 0, "2026-09-07")).toBe(false);
    expect(isCurrentPeriod("monthly", 2026, 9, 0, "2026-9-7")).toBe(false);
  });

  it("today を省略すると JST の今日を使う", () => {
    const today = todayJst();
    const year = Number(today.slice(0, 4));
    const month = Number(today.slice(5, 7));
    expect(isCurrentPeriod("monthly", year, month, 0)).toBe(true);

    const current = weekOfMonth(today);
    expect(
      isCurrentPeriod("weekly", current.year, current.month, current.week)
    ).toBe(true);
  });
});

describe("isPastPeriod（終わった期間＝確定期間かどうか）", () => {
  // 2026年9月 第2週 = 2026-09-06(日)〜2026-09-12(土)
  it("weekly: 最終日の翌日から true。最終日・期間中・期間より前（未来の期間）は false", () => {
    expect(isPastPeriod("weekly", 2026, 9, 2, "2026-09-13")).toBe(true); // 翌日
    expect(isPastPeriod("weekly", 2026, 9, 2, "2026-09-12")).toBe(false); // 最終日（当期）
    expect(isPastPeriod("weekly", 2026, 9, 2, "2026-09-06")).toBe(false); // 初日（当期）
    expect(isPastPeriod("weekly", 2026, 9, 2, "2026-09-05")).toBe(false); // 未来の期間
  });

  it("monthly / yearly", () => {
    expect(isPastPeriod("monthly", 2026, 9, 0, "2026-10-01")).toBe(true);
    expect(isPastPeriod("monthly", 2026, 9, 0, "2026-09-30")).toBe(false);
    expect(isPastPeriod("monthly", 2026, 10, 0, "2026-09-30")).toBe(false);
    expect(isPastPeriod("yearly", 2025, 0, 0, "2026-01-01")).toBe(true);
    expect(isPastPeriod("yearly", 2026, 0, 0, "2026-12-31")).toBe(false);
    expect(isPastPeriod("yearly", 2027, 0, 0, "2026-10-19")).toBe(false);
  });

  it("存在しない期間 / 不正な today は false", () => {
    expect(isPastPeriod("weekly", 2026, 9, 6, "2026-12-31")).toBe(false);
    expect(isPastPeriod("weekly", 2026, 9, 0, "2026-12-31")).toBe(false);
    expect(isPastPeriod("monthly", 2026, 9, 0, "2026-10-1")).toBe(false);
  });
});

describe("periodLabel / prevPeriodLabel（画面の期間ナビ）", () => {
  it("weekly は「YYYY年M月 第N週」", () => {
    expect(periodLabel("weekly", 2026, 3, 4)).toBe("2026年3月 第4週");
    expect(periodLabel("weekly", 2026, 12, 1)).toBe("2026年12月 第1週");
  });

  it("monthly は「YYYY年M月」。week は無視する", () => {
    // ★月次の week は 0（D-072）★
    expect(periodLabel("monthly", 2026, 3, 0)).toBe("2026年3月");
    expect(periodLabel("monthly", 2026, 3, 4)).toBe("2026年3月");
  });

  /**
   * ★段階5（D-072）の要件: `periodLabel` は「第0週」と出してはいけない★
   *
   * `week` の内部表現を `number | null` から `number`（月次は 0）へ変えたため、
   * `weekly` に 0 が渡ってくる経路ができた（`shiftPeriod` の monthly が 0 を返す）。
   * 旧実装の `week === null` 分岐と同じ挙動 = 月だけのラベルに落とすことを固定する。
   */
  it("★weekly で week=0（月次の予約値）でも「第0週」と出さず月だけのラベルに落とす★", () => {
    expect(periodLabel("weekly", 2026, 3, 0)).toBe("2026年3月");
    expect(periodLabel("weekly", 2026, 3, 0)).not.toContain("第0週");
    expect(periodLabel("weekly", 2026, 12, 0)).toBe("2026年12月");
    // 負の値（あり得ないが、渡ってきても「第-1週」を出さない）
    expect(periodLabel("weekly", 2026, 3, -1)).toBe("2026年3月");
    // 第1週は通常どおり出す（0 だけを落としていることの裏取り）
    expect(periodLabel("weekly", 2026, 3, 1)).toBe("2026年3月 第1週");
  });

  it("前期比のラベル", () => {
    expect(prevPeriodLabel("weekly")).toBe("前週");
    expect(prevPeriodLabel("monthly")).toBe("前月");
  });
});

describe("shiftPeriod（期間ナビの ‹ ›）", () => {
  // ★monthly は week に 0 を返す（D-072。null ではない）★
  it("monthly: 月を1つ動かす。week は 0 を返す", () => {
    expect(shiftPeriod("monthly", 2026, 9, 0, 1)).toEqual({
      year: 2026,
      month: 10,
      week: 0,
    });
    expect(shiftPeriod("monthly", 2026, 9, 0, -1)).toEqual({
      year: 2026,
      month: 8,
      week: 0,
    });
    // 週番号が入っていても monthly は読み捨てて 0 を返す
    expect(shiftPeriod("monthly", 2026, 9, 4, 1)).toEqual({
      year: 2026,
      month: 10,
      week: 0,
    });
  });

  it("monthly: 12月 +1 で翌年1月へ / 1月 -1 で前年12月へ", () => {
    expect(shiftPeriod("monthly", 2026, 12, 0, 1)).toEqual({
      year: 2027,
      month: 1,
      week: 0,
    });
    expect(shiftPeriod("monthly", 2026, 1, 0, -1)).toEqual({
      year: 2025,
      month: 12,
      week: 0,
    });
  });

  it("weekly: 同じ月の中を動く", () => {
    expect(shiftPeriod("weekly", 2026, 9, 1, 1)).toEqual({
      year: 2026,
      month: 9,
      week: 2,
    });
    expect(shiftPeriod("weekly", 2026, 9, 2, -1)).toEqual({
      year: 2026,
      month: 9,
      week: 1,
    });
  });

  // ★H-6 / 期待値表4（指示書H）: shiftPeriod が月と年を跨ぐこと★
  // | 起点 year/month/week | delta | 期待 year/month/week | 何を固定するか |
  const shiftTable: Array<
    [number, number, number, -1 | 1, number, number, number, string]
  > = [
    [2026, 8, 6, -1, 2026, 8, 5, "8月第6週(2日)の前は8月第5週"],
    [2026, 8, 6, 1, 2026, 9, 1, "★8月第6週の次は9月第1週（月をまたぐ）★"],
    [2026, 9, 1, -1, 2026, 8, 6, "★旧D-067では 8月第5週 だった★"],
    [2026, 9, 1, 1, 2026, 9, 2, "9月第1週の次は9月第2週"],
    [2026, 8, 1, -1, 2026, 7, 5, "★旧D-067では 7月第4週 だった★。1日だけの週(08-01)の前"],
    [2026, 8, 1, 1, 2026, 8, 2, "1日だけの週の次は8月第2週"],
    [2026, 9, 5, 1, 2026, 10, 1, "★月末の週の次は10月第1週★"],
    [2026, 1, 1, -1, 2025, 12, 5, "★年をまたぐ。旧D-067では 2025年12月第4週 だった★"],
    [2025, 12, 5, 1, 2026, 1, 1, "★年をまたいで戻る★"],
    [2026, 8, 5, 1, 2026, 8, 6, "★旧D-067では 9月第1週 だった（8月の最終週ではない）★"],
    [2026, 12, 4, 1, 2026, 12, 5, "★旧D-067では 2027年1月第1週 だった★"],
  ];

  for (const [
    year,
    month,
    week,
    delta,
    expectedYear,
    expectedMonth,
    expectedWeek,
    intent,
  ] of shiftTable) {
    const sign = delta > 0 ? "+1" : "-1";
    it(`★H-6★ ${year}年${month}月 第${week}週 ${sign} → ${expectedYear}年${expectedMonth}月 第${expectedWeek}週（${intent}）`, () => {
      expect(shiftPeriod("weekly", year, month, week, delta)).toEqual({
        year: expectedYear,
        month: expectedMonth,
        week: expectedWeek,
      });
    });
  }

  it("weekly: +1 と -1 は往復する（2026年を通しで確認）", () => {
    let cursor: { year: number; month: number; week: number } = {
      year: 2026,
      month: 1,
      week: 1,
    };
    for (let i = 0; i < 60; i++) {
      const next = shiftPeriod(
        "weekly",
        cursor.year,
        cursor.month,
        cursor.week,
        1
      );
      const back = shiftPeriod("weekly", next.year, next.month, next.week, -1);
      expect(back).toEqual(cursor);
      cursor = next;
    }
  });

  // ★「週が未選択」は week=0（月次の予約値。旧実装の null と同じ扱い。D-072）★
  it("weekly で week が無い（0）ときは第1週から動かす", () => {
    expect(shiftPeriod("weekly", 2026, 9, 0, 1)).toEqual({
      year: 2026,
      month: 9,
      week: 2,
    });
    expect(shiftPeriod("weekly", 2026, 9, 0, -1)).toEqual({
      year: 2026,
      month: 8,
      week: 6,
    });
  });
});

describe("clampWeek（移動先の月に無い週番号を丸める。★D-072★）", () => {
  // | 入力 | 期待 | 何を固定するか |
  const table: Array<[number, number, number, number, string]> = [
    [2026, 9, 6, 5, "9月は5週なので第5週へ丸める"],
    [2026, 8, 6, 6, "8月は6週あるのでそのまま"],
    [2026, 2, 5, 4, "2026年2月は4週"],
    [2026, 9, 0, 1, "下限も丸める"],
    [2026, 9, 3, 3, "範囲内はそのまま"],
  ];

  for (const [year, month, week, expected, intent] of table) {
    it(`clampWeek(${year}, ${month}, ${week}) → ${expected}（${intent}）`, () => {
      expect(clampWeek(year, month, week)).toBe(expected);
    });
  }
});

/**
 * ★年次（yearly）★ — 確定期間のサマリに週・月・年の3粒度が入るようになったため追加
 * （指示書AQ 決定1。2026-09-30）。
 *
 * ★年次は `month=0` / `week=0` が前提★（CLAUDE.md §4.7「`month=0 AND week=0` が年次」）。
 * `periodRange` の `monthly` が `week` を無視するのと同じ扱いで、`yearly` は
 * ★`month` / `week` をどちらも無視する★（0 以外が来ても落とさず年で締める）。
 *
 * ★`weekOfMonth()` は触っていない★（月跨ぎ週の帰属ルールの唯一の持ち主。D-071）。
 *
 * 期待値表（先に確定 → テスト → 実装。CLAUDE.md §0-2 / §9.4）:
 *
 * | #    | 関数            | 入力                                 | 期待値                   | 何を固定するか |
 * |------|-----------------|--------------------------------------|--------------------------|---|
 * | Y-1  | periodRange     | ("yearly", 2026, 0, 0)               | 2026-01-01 〜 2026-12-31 | 年次の基本形。★`month=0` で null にならないこと★ |
 * | Y-2  | periodRange     | ("yearly", 2028, 0, 0)               | 2028-01-01 〜 2028-12-31 | うるう年でも終端は 12-31 |
 * | Y-3  | periodRange     | 2028年の日数                          | ★366日★                 | うるう日 02-29 が範囲に入る |
 * | Y-4  | periodRange     | 2026年の日数                          | 365日                    | 平年 |
 * | Y-5  | periodRange     | ("yearly", 2026, 9, 3)               | 2026-01-01 〜 2026-12-31 | month / week は無視する |
 * | Y-6  | periodRange     | ("yearly", 2026.5, 0, 0)             | null                     | year が整数でないものは弾く |
 * | Y-7  | isCurrentPeriod | ("yearly", 2026, 0, 0, "2026-09-30") | ★true★                  | 当年は当期 |
 * | Y-8  | isCurrentPeriod | ("yearly", 2026, 0, 0, "2026-01-01") | true                     | 初日（境界） |
 * | Y-9  | isCurrentPeriod | ("yearly", 2026, 0, 0, "2026-12-31") | true                     | 最終日（境界） |
 * | Y-10 | isCurrentPeriod | ("yearly", 2026, 0, 0, "2025-12-31") | false                    | 前日 |
 * | Y-11 | isCurrentPeriod | ("yearly", 2026, 0, 0, "2027-01-01") | false                    | 翌日 |
 * | Y-12 | isCurrentPeriod | ("yearly", 2025, 0, 0, "2026-09-30") | ★false★                 | 前年は当期ではない |
 * | Y-13 | isCurrentPeriod | ("yearly", 2028, 0, 0, "2028-02-29") | true                     | うるう日 |
 * | Y-14 | periodLabel     | ("yearly", 2026, 0, 0)               | "2026年"                 | 「2026年0月」と出さない |
 * | Y-15 | periodLabel     | ("yearly", 2026, 9, 3)               | "2026年"                 | month / week を出さない |
 * | Y-16 | prevPeriodLabel | "yearly"                             | "前年"                    | |
 * | Y-17 | shiftPeriod     | ("yearly", 2026, 0, 0, -1)           | { 2025, 0, 0 }           | year を ±1、month / week は 0 のまま |
 * | Y-18 | shiftPeriod     | ("yearly", 2026, 0, 0, +1)           | { 2027, 0, 0 }           | |
 * | Y-19 | shiftPeriod     | ("yearly", 2026, 9, 3, +1)           | { 2027, ★0★, ★0★ }     | 粒度を切り替えた直後の残骸を 0 に落とす |
 * | Y-20 | shiftPeriod     | 往復（+1 → -1）                       | 元に戻る                  | |
 *
 * ★既存の週次・月次は1件も変えていない★（上の describe 群がそのまま通ることで固定）。
 */
describe("yearly（年次）。★month=0 / week=0 が前提★", () => {
  /** [from, to] の日数（両端含む）。うるう年の検算用 */
  const daysInclusive = (from: string, to: string): number => {
    const ms = (date: string): number => {
      const [y, m, d] = date.split("-").map(Number);
      return Date.UTC(y, m - 1, d);
    };
    return (ms(to) - ms(from)) / 86_400_000 + 1;
  };

  describe("periodRange", () => {
    it("★Y-1★ 2026年 → 2026-01-01 〜 2026-12-31（★month=0 でも null にならない★）", () => {
      expect(periodRange("yearly", 2026, 0, 0)).toEqual({
        from: "2026-01-01",
        to: "2026-12-31",
      });
    });

    it("★Y-2 / Y-3★ うるう年 2028年も終端は 12-31。範囲は366日（02-29 を含む）", () => {
      const range = periodRange("yearly", 2028, 0, 0);
      expect(range).toEqual({ from: "2028-01-01", to: "2028-12-31" });
      expect(daysInclusive(range!.from, range!.to)).toBe(366);
      // 辞書順 = 日付順なので、うるう日が範囲内にあることは文字列比較で確認できる
      expect(range!.from <= "2028-02-29" && "2028-02-29" <= range!.to).toBe(true);
    });

    it("★Y-4★ 平年 2026年は365日", () => {
      const range = periodRange("yearly", 2026, 0, 0);
      expect(daysInclusive(range!.from, range!.to)).toBe(365);
    });

    it("★Y-5★ month / week は無視する（monthly が week を無視するのと同じ）", () => {
      const expected = { from: "2026-01-01", to: "2026-12-31" };
      expect(periodRange("yearly", 2026, 9, 3)).toEqual(expected);
      expect(periodRange("yearly", 2026, 12, 6)).toEqual(expected);
      // ★weekly なら null になる組み合わせでも、yearly は年で締める★
      expect(periodRange("weekly", 2026, 9, 6)).toBeNull();
      expect(periodRange("yearly", 2026, 9, 6)).toEqual(expected);
    });

    it("★Y-6★ year が整数でなければ null", () => {
      expect(periodRange("yearly", 2026.5, 0, 0)).toBeNull();
      expect(periodRange("yearly", Number.NaN, 0, 0)).toBeNull();
    });

    it("年をまたいで連続する（2026年の翌日が2027年の初日）", () => {
      expect(periodRange("yearly", 2026, 0, 0)?.to).toBe("2026-12-31");
      expect(periodRange("yearly", 2027, 0, 0)?.from).toBe("2027-01-01");
    });
  });

  describe("isCurrentPeriod", () => {
    // | 年 | today | 期待 | 何を固定するか |
    const table: Array<[number, string, boolean, string]> = [
      [2026, "2026-09-30", true, "★Y-7★ 当年は当期"],
      [2026, "2026-01-01", true, "★Y-8★ 初日（境界を含む）"],
      [2026, "2026-12-31", true, "★Y-9★ 最終日（境界を含む）"],
      [2026, "2025-12-31", false, "★Y-10★ 前日"],
      [2026, "2027-01-01", false, "★Y-11★ 翌日"],
      [2025, "2026-09-30", false, "★Y-12★ 前年は当期ではない"],
      [2027, "2026-09-30", false, "翌年も当期ではない"],
      [2028, "2028-02-29", true, "★Y-13★ うるう日"],
    ];

    for (const [year, today, expected, intent] of table) {
      it(`isCurrentPeriod("yearly", ${year}, 0, 0, "${today}") → ${expected}（${intent}）`, () => {
        expect(isCurrentPeriod("yearly", year, 0, 0, today)).toBe(expected);
      });
    }

    it("不正な today は false", () => {
      expect(isCurrentPeriod("yearly", 2026, 0, 0, "2026/09/30")).toBe(false);
      expect(isCurrentPeriod("yearly", 2026, 0, 0, "")).toBe(false);
    });

    it("today を省略すると JST の今日を使う（当年は true）", () => {
      const thisYear = Number(todayJst().slice(0, 4));
      expect(isCurrentPeriod("yearly", thisYear, 0, 0)).toBe(true);
      expect(isCurrentPeriod("yearly", thisYear - 1, 0, 0)).toBe(false);
      expect(isCurrentPeriod("yearly", thisYear + 1, 0, 0)).toBe(false);
    });
  });

  describe("periodLabel / prevPeriodLabel", () => {
    it("★Y-14 / Y-15★ yearly は「YYYY年」。month / week は出さない", () => {
      expect(periodLabel("yearly", 2026, 0, 0)).toBe("2026年");
      expect(periodLabel("yearly", 2026, 9, 3)).toBe("2026年");
      expect(periodLabel("yearly", 2028, 12, 6)).toBe("2028年");
      // ★「2026年0月」「第0週」と出さないこと★
      expect(periodLabel("yearly", 2026, 0, 0)).not.toContain("月");
      expect(periodLabel("yearly", 2026, 0, 0)).not.toContain("週");
    });

    it("★Y-16★ 前期比のラベルは「前年」", () => {
      expect(prevPeriodLabel("yearly")).toBe("前年");
      // 既存の2つは変えていない
      expect(prevPeriodLabel("weekly")).toBe("前週");
      expect(prevPeriodLabel("monthly")).toBe("前月");
    });
  });

  describe("shiftPeriod", () => {
    it("★Y-17 / Y-18★ year を ±1 し、month / week は 0 のまま", () => {
      expect(shiftPeriod("yearly", 2026, 0, 0, -1)).toEqual({
        year: 2025,
        month: 0,
        week: 0,
      });
      expect(shiftPeriod("yearly", 2026, 0, 0, 1)).toEqual({
        year: 2027,
        month: 0,
        week: 0,
      });
    });

    it("★Y-19★ month / week に 0 以外が残っていても 0 に落とす（粒度切替直後の残骸）", () => {
      expect(shiftPeriod("yearly", 2026, 9, 3, 1)).toEqual({
        year: 2027,
        month: 0,
        week: 0,
      });
      expect(shiftPeriod("yearly", 2026, 8, 6, -1)).toEqual({
        year: 2025,
        month: 0,
        week: 0,
      });
    });

    it("★Y-20★ +1 と -1 は往復する（2020〜2040）", () => {
      for (let year = 2020; year <= 2040; year++) {
        const next = shiftPeriod("yearly", year, 0, 0, 1);
        expect(shiftPeriod("yearly", next.year, next.month, next.week, -1)).toEqual({
          year,
          month: 0,
          week: 0,
        });
      }
    });
  });

  it("★週次・月次の分岐に手を入れていないこと★（yearly 追加前の代表値を再確認）", () => {
    expect(periodRange("monthly", 2026, 9, 0)).toEqual({
      from: "2026-09-01",
      to: "2026-09-30",
    });
    expect(periodRange("weekly", 2026, 9, 1)).toEqual({
      from: "2026-09-01",
      to: "2026-09-05",
    });
    expect(periodRange("weekly", 2026, 9, 0)).toBeNull();
    expect(periodRange("monthly", 2026, 0, 0)).toBeNull();
    expect(periodLabel("weekly", 2026, 9, 2)).toBe("2026年9月 第2週");
    expect(periodLabel("monthly", 2026, 9, 0)).toBe("2026年9月");
    expect(shiftPeriod("monthly", 2026, 12, 0, 1)).toEqual({
      year: 2027,
      month: 1,
      week: 0,
    });
  });
});
