import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import planRoutes from "../routes/admin/plan";
import { localDateStr } from "../utils/date";
import {
  resolveCell,
  resolveGrid,
  listPlanUsers,
  assertNotPastDate,
  createEntry,
  updateEntry,
  deleteEntry,
  createOverride,
  updateOverride,
  deleteOverride,
  listEntries,
  listOverrides,
  bulkCreateEntries,
} from "../services/plan.service";

// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------

/** Note-prefix for test-created rows so we can clean them up safely. */
const N = "wh_plan_";

let adminUserId: number;
// A DEDICATED, distinct non-admin user for the service-level employee-scoping
// tests (and as the second employee in the multi-user bulk test). Creating our
// own user — instead of grabbing "the second user in the DB" — guarantees it is
// never accidentally the admin (which would make `toEqual([])` pass for the
// wrong reason on a single-user DB), and that it is genuinely distinct so the
// 2-employee aggregate assertion can't collapse to 1.
let scopeUserId: number;
let scopeRoleId: number;
let noPermUserId: number;

beforeAll(async () => {
  // Pick the first admin user from the test database as our fixture.
  // The users -> roles relation is named `roles` in the Prisma schema
  // (users.role_id references roles.id).
  const admin = await prisma.users.findFirst({
    where: { roles: { name: "admin" } },
  });
  if (!admin) throw new Error("Test setup: admin user not found");
  adminUserId = admin.id;

  // Dedicated non-admin user + role for scoping/multi-user tests.
  const stamp = Date.now();
  const role = await prisma.roles.create({
    data: {
      name: `scope_plan_${stamp}`,
      display_name: "Plan Scope Test",
    },
  });
  scopeRoleId = role.id;
  const user = await prisma.users.create({
    data: {
      username: `scope_plan_${stamp}`,
      first_name: "Scope",
      last_name: "User",
      email: `scope_plan_${stamp}@test.local`,
      password_hash:
        "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
      role_id: role.id,
      is_active: true,
    },
  });
  scopeUserId = user.id;
  if (scopeUserId === adminUserId)
    throw new Error("scope user must be distinct from admin");
});

afterAll(async () => {
  // Clean up the dedicated scope user/role created above. Wrapped in catch so a
  // leftover FK (none expected — its rows are note-prefixed and removed) can't
  // mask real failures.
  if (scopeUserId)
    await prisma.users
      .deleteMany({ where: { id: scopeUserId } })
      .catch(() => {});
  if (scopeRoleId)
    await prisma.roles
      .deleteMany({ where: { id: scopeRoleId } })
      .catch(() => {});
});

beforeEach(async () => {
  // Clean up any leftover test data from previous runs. We only touch rows
  // whose `note` contains our test prefix, so we never disturb real data.
  await prisma.work_plan_entries.deleteMany({
    where: { note: { contains: N } },
  });
  await prisma.work_plan_overrides.deleteMany({
    where: { note: { contains: N } },
  });
  // Also clean up empty-note rows on the specific test dates used by the
  // "allows an empty note" tests. These rows are not caught by the prefix
  // filter above, and would cause the per-cell cap check to reject them
  // once three accumulate across runs.
  await prisma.work_plan_entries.deleteMany({
    where: {
      date_from: {
        in: [
          new Date("2099-07-15T00:00:00.000Z"),
          new Date("2097-08-05T00:00:00.000Z"),
        ],
      },
    },
  });
});

afterAll(async () => {
  // Final cleanup so we never leave soft-deleted test rows behind.
  await prisma.work_plan_entries.deleteMany({
    where: { note: { contains: N } },
  });
  await prisma.work_plan_overrides.deleteMany({
    where: { note: { contains: N } },
  });
});

// ---------------------------------------------------------------------------
// resolveCell
// ---------------------------------------------------------------------------

describe("plan.service.resolveCell", () => {
  it("returns [] when nothing covers the date", async () => {
    const result = await resolveCell(adminUserId, "2099-01-01");
    expect(result).toEqual([]);
  });

  it("returns the entry that covers the date", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-10"),
        category: "work",
        note: `${N}PLC upgrade`,
        created_by: adminUserId,
      },
    });
    const result = await resolveCell(adminUserId, "2099-06-05");
    expect(result.length).toBe(1);
    expect(result[0].source).toBe("entry");
    expect(result[0].note).toBe(`${N}PLC upgrade`);
    expect(result[0].rangeFrom).toBe("2099-06-01");
    expect(result[0].rangeTo).toBe("2099-06-10");
  });

  it("returns overrides (entries hidden) when an override exists", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-10"),
        category: "work",
        note: `${N}PLC upgrade`,
        created_by: adminUserId,
      },
    });
    await prisma.work_plan_overrides.create({
      data: {
        user_id: adminUserId,
        shift_date: new Date("2099-06-05"),
        category: "leave",
        note: `${N}Volno po noční`,
        created_by: adminUserId,
      },
    });
    const result = await resolveCell(adminUserId, "2099-06-05");
    expect(result.length).toBe(1);
    expect(result[0].source).toBe("override");
    expect(result[0].note).toBe(`${N}Volno po noční`);
  });

  it("returns multiple additive entries newest-first", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-10"),
        category: "work",
        note: `${N}first`,
        created_by: adminUserId,
      },
    });
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-05"),
        date_to: new Date("2099-06-05"),
        category: "work",
        note: `${N}second`,
        created_by: adminUserId,
      },
    });
    const result = await resolveCell(adminUserId, "2099-06-05");
    expect(result.length).toBe(2);
    expect(result[0].note).toBe(`${N}second`); // newest first
  });

  it("caps the returned records at MAX_RECORDS_PER_CELL", async () => {
    for (let i = 0; i < 4; i++) {
      await prisma.work_plan_overrides.create({
        data: {
          user_id: adminUserId,
          shift_date: new Date("2099-06-06"),
          category: "leave",
          note: `${N}cap${i}`,
          created_by: adminUserId,
        },
      });
    }
    const result = await resolveCell(adminUserId, "2099-06-06");
    expect(result.length).toBe(3);
  });

  it("ignores soft-deleted entries and overrides", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-10"),
        category: "work",
        note: `${N}deleted entry`,
        is_deleted: true,
        created_by: adminUserId,
      },
    });
    const result = await resolveCell(adminUserId, "2099-06-05");
    expect(result).toEqual([]);
  });
});

// ---------------------------------------------------------------------------
// resolveGrid
// ---------------------------------------------------------------------------

describe("plan.service.resolveGrid", () => {
  it("returns a 2D map keyed by userId then date", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-03"),
        category: "work",
        note: `${N}A`,
        created_by: adminUserId,
      },
    });
    const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
    expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
    expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`);
    expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
    expect(cells[adminUserId]["2099-06-04"]).toEqual([]);
    expect(cells[adminUserId]["2099-06-05"]).toEqual([]);
  });

  it("applies override on a covered day", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-03"),
        category: "work",
        note: `${N}A`,
        created_by: adminUserId,
      },
    });
    await prisma.work_plan_overrides.create({
      data: {
        user_id: adminUserId,
        shift_date: new Date("2099-06-02"),
        category: "leave",
        note: `${N}B`,
        created_by: adminUserId,
      },
    });
    const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
    expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
    expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
    expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
  });

  it("stacks additive entries newest-first and caps the day at MAX_RECORDS_PER_CELL", async () => {
    // Two entries cover 2099-06-12 → both appear, newest-first. A 3rd entry
    // covering the same day and a 4th overlapping one push past the cap, so the
    // day must still return exactly 3 (mirrors resolveCell's cap behaviour).
    //
    // created_at is @db.Timestamp(0) (second precision, MySQL TIMESTAMP range
    // tops out at 2038), so rows created in the same second tie on the
    // `created_at desc` sort. We set explicit, distinct in-range created_at
    // values so the newest-first assertion is deterministic.
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-11"),
        date_to: new Date("2099-06-12"),
        category: "work",
        note: `${N}g1`,
        created_by: adminUserId,
        created_at: new Date("2037-06-01T08:00:00.000Z"),
      },
    });
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-12"),
        date_to: new Date("2099-06-12"),
        category: "work",
        note: `${N}g2`,
        created_by: adminUserId,
        created_at: new Date("2037-06-01T09:00:00.000Z"), // newer than g1
      },
    });
    // Two-entry day: assert length 2 and newest-first ordering.
    const twoDay = await resolveGrid([adminUserId], "2099-06-11", "2099-06-12");
    expect(twoDay[adminUserId]["2099-06-12"].length).toBe(2);
    expect(twoDay[adminUserId]["2099-06-12"][0].note).toBe(`${N}g2`); // newest first
    // Day with no second entry stays single.
    expect(twoDay[adminUserId]["2099-06-11"].length).toBe(1);

    // Add a 3rd and a 4th overlapping entry on 2099-06-12 to exceed the cap.
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-12"),
        date_to: new Date("2099-06-12"),
        category: "work",
        note: `${N}g3`,
        created_by: adminUserId,
        created_at: new Date("2037-06-01T10:00:00.000Z"),
      },
    });
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-12"),
        date_to: new Date("2099-06-12"),
        category: "work",
        note: `${N}g4`,
        created_by: adminUserId,
        created_at: new Date("2037-06-01T11:00:00.000Z"),
      },
    });
    const cappedDay = await resolveGrid(
      [adminUserId],
      "2099-06-12",
      "2099-06-12",
    );
    expect(cappedDay[adminUserId]["2099-06-12"].length).toBe(3);
  });
});

// ---------------------------------------------------------------------------
// listPlanUsers
// ---------------------------------------------------------------------------

describe("plan.service.listPlanUsers", () => {
  it("returns at least the admin user", async () => {
    const users = await listPlanUsers();
    const found = users.find((u) => u.id === adminUserId);
    expect(found).toBeDefined();
    expect(found?.has_attendance_record).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// assertNotPastDate
// ---------------------------------------------------------------------------

describe("plan.service.assertNotPastDate", () => {
  it("returns error for a past date", () => {
    const result = assertNotPastDate("2000-01-01", false);
    expect(result).toEqual({
      error:
        "Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.",
      status: 403,
    });
  });

  it("returns null for a future date", () => {
    const result = assertNotPastDate("2099-01-01", false);
    expect(result).toBeNull();
  });

  it("returns null for a past date with force=true", () => {
    const result = assertNotPastDate("2000-01-01", true);
    expect(result).toBeNull();
  });

  it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => {
    // Deterministic without mocking the clock: compute "today" exactly the way
    // the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in
    // config/env.ts). The boundary is the bug-prone path — an off-by-one here
    // would wrongly reject editing today's plan.
    const today = localDateStr(new Date());
    expect(assertNotPastDate(today, false)).toBeNull();
  });

  it("rejects YESTERDAY (local Prague date) as a past date", () => {
    const d = new Date();
    d.setDate(d.getDate() - 1); // local-time yesterday
    const yesterday = localDateStr(d);
    const result = assertNotPastDate(yesterday, false);
    expect(result).not.toBeNull();
    expect(result?.status).toBe(403);
  });
});

// ---------------------------------------------------------------------------
// createEntry
// ---------------------------------------------------------------------------

describe("plan.service.createEntry", () => {
  it("creates an entry and returns { data, oldData: null }", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-07-01",
        date_to: "2099-07-05",
        category: "work",
        note: `${N}new entry`,
      },
      adminUserId,
      false,
    );
    expect("data" in result).toBe(true);
    if ("data" in result) {
      expect(result.data.note).toBe(`${N}new entry`);
      expect(result.oldData).toBeNull();
    }
  });

  it("rejects a past date without force", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2000-01-01",
        date_to: "2000-01-02",
        category: "work",
        note: `${N}past`,
      },
      adminUserId,
      false,
    );
    expect("error" in result).toBe(true);
    if ("error" in result) expect(result.status).toBe(403);
  });

  it("allows a past date with force", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2000-01-01",
        date_to: "2000-01-02",
        category: "work",
        note: `${N}past forced`,
      },
      adminUserId,
      true,
    );
    expect("data" in result).toBe(true);
  });

  it("rejects when date_to < date_from", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-07-10",
        date_to: "2099-07-05",
        category: "work",
        note: `${N}invalid range`,
      },
      adminUserId,
      false,
    );
    expect("error" in result).toBe(true);
  });

  it("allows an empty note (text is optional)", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-07-15",
        date_to: "2099-07-15",
        category: "work",
        note: "",
      },
      adminUserId,
      false,
    );
    expect("data" in result).toBe(true);
  });

  it("rejects a range whose date_to is in the past, even if date_from is today", async () => {
    // The range check `date_to >= date_from` fires before the past-date
    // check, so a backwards range returns 400 (correct UX). The to-endpoint
    // past-date check matters when updating an existing entry — see the
    // matching test in updateEntry below. Here we just confirm that a
    // range whose start is in the past is rejected as 403.
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2000-01-01",
        date_to: "2000-01-05",
        category: "work",
        note: `${N}past-both`,
      },
      adminUserId,
      false,
    );
    expect("error" in result).toBe(true);
    if ("error" in result) expect(result.status).toBe(403);
  });

  it("rejects a 4th entry covering a day already at the cap", async () => {
    for (let i = 0; i < 3; i++) {
      const r = await createEntry(
        {
          user_id: adminUserId,
          date_from: "2099-07-20",
          date_to: "2099-07-20",
          category: "work",
          note: `${N}e${i}`,
        },
        adminUserId,
        false,
      );
      expect("data" in r).toBe(true);
    }
    const fourth = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-07-20",
        date_to: "2099-07-20",
        category: "work",
        note: `${N}e3`,
      },
      adminUserId,
      false,
    );
    expect("error" in fourth).toBe(true);
    if ("error" in fourth) expect(fourth.status).toBe(400);
  });
});

// ---------------------------------------------------------------------------
// updateEntry
// ---------------------------------------------------------------------------

describe("plan.service.updateEntry", () => {
  it("updates the note and returns the old data", async () => {
    const created = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-08-01",
        date_to: "2099-08-03",
        category: "work",
        note: `${N}original`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup failed");

    const updated = await updateEntry(
      created.data.id,
      { note: `${N}updated` },
      adminUserId,
      false,
    );
    expect("data" in updated).toBe(true);
    if ("data" in updated) {
      expect((updated.data as any).note).toBe(`${N}updated`);
      expect((updated.oldData as any).note).toBe(`${N}original`);
    }
  });

  it("rejects an update that pushes date_to into the past", async () => {
    // Future entry, but the user tries to extend its end backwards into
    // the past. The to-endpoint past-date check should fire.
    const created = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-08-10",
        date_to: "2099-08-15",
        category: "work",
        note: `${N}to-shorten`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup failed");
    const updated = await updateEntry(
      created.data.id,
      { date_to: "2000-01-05" },
      adminUserId,
      false,
    );
    expect("error" in updated).toBe(true);
    if ("error" in updated) expect(updated.status).toBe(403);
  });
});

// ---------------------------------------------------------------------------
// deleteEntry
// ---------------------------------------------------------------------------

describe("plan.service.deleteEntry", () => {
  it("soft-deletes the row and returns the old data", async () => {
    const created = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-09-01",
        date_to: "2099-09-01",
        category: "work",
        note: `${N}to delete`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup failed");

    const result = await deleteEntry(created.data.id, adminUserId, false);
    expect("data" in result).toBe(true);
    if ("data" in result) {
      expect(result.data).toEqual({ ok: true });
      expect((result.oldData as any).note).toBe(`${N}to delete`);
    }

    const stillThere = await prisma.work_plan_entries.findUnique({
      where: { id: created.data.id },
    });
    expect(stillThere?.is_deleted).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// createOverride
// ---------------------------------------------------------------------------

describe("plan.service.createOverride", () => {
  it("creates an override and returns { data, oldData: null }", async () => {
    const result = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-10-01",
        category: "leave",
        note: `${N}day off`,
      },
      adminUserId,
      false,
    );
    expect("data" in result).toBe(true);
    if ("data" in result) {
      expect(result.data.note).toBe(`${N}day off`);
      expect(result.oldData).toBeNull();
    }
  });

  it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => {
    for (let i = 0; i < 3; i++) {
      const r = await createOverride(
        {
          user_id: adminUserId,
          shift_date: "2099-10-02",
          category: "leave",
          note: `${N}o${i}`,
        },
        adminUserId,
        false,
      );
      expect("data" in r).toBe(true);
    }
    // A 4th record on the same day is rejected by the cap.
    const fourth = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-10-02",
        category: "leave",
        note: `${N}o3`,
      },
      adminUserId,
      false,
    );
    expect("error" in fourth).toBe(true);
    if ("error" in fourth) expect(fourth.status).toBe(400);
    // All three earlier overrides remain active — no replace happened.
    const active = await prisma.work_plan_overrides.findMany({
      where: {
        user_id: adminUserId,
        shift_date: new Date("2099-10-02"),
        is_deleted: false,
      },
    });
    expect(active.length).toBe(3);
  });

  it("rejects a past date without force", async () => {
    const result = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2000-01-01",
        category: "leave",
        note: `${N}past`,
      },
      adminUserId,
      false,
    );
    expect("error" in result).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// updateOverride
// ---------------------------------------------------------------------------

describe("plan.service.updateOverride", () => {
  it("updates the note and returns the old data", async () => {
    const created = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-11-01",
        category: "leave",
        note: `${N}original`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup");
    const updated = await updateOverride(
      created.data.id,
      { note: `${N}updated` },
      adminUserId,
      false,
    );
    expect("data" in updated).toBe(true);
    if ("data" in updated) {
      expect((updated.data as any).note).toBe(`${N}updated`);
      expect((updated.oldData as any).note).toBe(`${N}original`);
    }
  });
});

// ---------------------------------------------------------------------------
// deleteOverride
// ---------------------------------------------------------------------------

describe("plan.service.deleteOverride", () => {
  it("soft-deletes the override and returns the old data", async () => {
    const created = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-12-01",
        category: "leave",
        note: `${N}to delete`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup");
    const result = await deleteOverride(created.data.id, adminUserId, false);
    expect("data" in result).toBe(true);
    if ("data" in result) {
      expect(result.data).toEqual({ ok: true });
      expect((result.oldData as any).note).toBe(`${N}to delete`);
    }
    const still = await prisma.work_plan_overrides.findUnique({
      where: { id: created.data.id },
    });
    expect(still?.is_deleted).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// listEntries / listOverrides (employee scoping)
// ---------------------------------------------------------------------------

describe("plan.service.listEntries (employee scoping)", () => {
  it("returns entries for the actor user when scoped", async () => {
    await createEntry(
      {
        user_id: adminUserId,
        date_from: "2098-01-01",
        date_to: "2098-01-05",
        category: "work",
        note: `${N}scope test`,
      },
      adminUserId,
      false,
    );
    const rows = await listEntries(
      { user_id: adminUserId, date_from: "2098-01-01", date_to: "2098-01-31" },
      adminUserId,
      true, // isAdmin
    );
    expect(rows.length).toBeGreaterThan(0);
  });

  it("scopes non-admin to actor user_id when querying for another user", async () => {
    await createEntry(
      {
        user_id: adminUserId,
        date_from: "2098-02-01",
        date_to: "2098-02-05",
        category: "work",
        note: `${N}admin entry`,
      },
      adminUserId,
      false,
    );
    const rows = await listEntries(
      { user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
      scopeUserId, // dedicated distinct non-admin actor (never the admin)
      false, // not admin
    );
    // A non-admin actor querying ANOTHER user's data is scoped to its own
    // user_id, so it sees none of the admin's entries. This can only be
    // [] for the right reason because scopeUserId !== adminUserId (asserted
    // in beforeAll).
    expect(rows).toEqual([]);
  });
});

describe("plan.service.listOverrides (employee scoping)", () => {
  it("scopes non-admin to actor user_id", async () => {
    await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2098-03-01",
        category: "leave",
        note: `${N}admin override`,
      },
      adminUserId,
      false,
    );
    const rows = await listOverrides(
      { user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
      scopeUserId, // dedicated distinct non-admin actor (never the admin)
      false,
    );
    expect(rows).toEqual([]);
  });
});

// ---------------------------------------------------------------------------
// bulkCreateEntries
// ---------------------------------------------------------------------------

describe("plan.service.bulkCreateEntries", () => {
  it("creates one continuous range per employee when weekends are included", async () => {
    // 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
    // range 06-01..06-03 covering all 3 days.
    const res = await bulkCreateEntries(
      {
        user_ids: [adminUserId],
        date_from: "2096-06-01",
        date_to: "2096-06-03",
        include_weekends: true,
        project_id: null,
        category: "work",
        note: `${N}bulk-wknd`,
      },
      adminUserId,
    );
    expect("data" in res).toBe(true);
    if (!("data" in res)) return;
    expect(res.data.created_entries).toBe(1);
    expect(res.data.created_days).toBe(3);
    expect(res.data.skipped_days).toBe(0);
    expect(res.data.users).toBe(1);
  });

  it("splits into per-work-week ranges and skips weekends when excluded", async () => {
    // 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
    // Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
    const res = await bulkCreateEntries(
      {
        user_ids: [adminUserId],
        date_from: "2096-06-01",
        date_to: "2096-06-05",
        include_weekends: false,
        project_id: null,
        category: "work",
        note: `${N}bulk-week`,
      },
      adminUserId,
    );
    expect("data" in res).toBe(true);
    if (!("data" in res)) return;
    expect(res.data.created_entries).toBe(2);
    expect(res.data.created_days).toBe(3);
    expect(res.data.skipped_days).toBe(0);
  });

  it("skips days already at the cap and splits the range around them", async () => {
    // Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
    // bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
    // 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
    for (let i = 0; i < 3; i++) {
      await prisma.work_plan_entries.create({
        data: {
          user_id: adminUserId,
          date_from: new Date("2096-07-02"),
          date_to: new Date("2096-07-02"),
          category: "work",
          note: `${N}cap${i}`,
          created_by: adminUserId,
        },
      });
    }
    const res = await bulkCreateEntries(
      {
        user_ids: [adminUserId],
        date_from: "2096-07-01",
        date_to: "2096-07-03",
        include_weekends: true,
        project_id: null,
        category: "work",
        note: `${N}bulk-cap`,
      },
      adminUserId,
    );
    expect("data" in res).toBe(true);
    if (!("data" in res)) return;
    expect(res.data.created_entries).toBe(2);
    expect(res.data.created_days).toBe(2);
    expect(res.data.skipped_days).toBe(1);
  });

  it("aggregates across multiple employees", async () => {
    // Two genuinely distinct employees (admin + the dedicated scope user) so
    // `users` can't collapse to 1 if the DB happened to have a single user.
    const res = await bulkCreateEntries(
      {
        user_ids: [adminUserId, scopeUserId],
        date_from: "2096-08-03", // Friday
        date_to: "2096-08-03",
        include_weekends: true,
        project_id: null,
        category: "work",
        note: `${N}bulk-multi`,
      },
      adminUserId,
    );
    expect("data" in res).toBe(true);
    if (!("data" in res)) return;
    expect(res.data.users).toBe(2);
    expect(res.data.created_entries).toBe(2);
    expect(res.data.created_days).toBe(2);
  });

  it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
    const empty = await bulkCreateEntries(
      {
        user_ids: [],
        date_from: "2096-06-01",
        date_to: "2096-06-01",
        include_weekends: true,
        project_id: null,
        category: "work",
        note: `${N}x`,
      },
      adminUserId,
    );
    expect("error" in empty && empty.status).toBe(400);

    const tooLong = await bulkCreateEntries(
      {
        user_ids: [adminUserId],
        date_from: "2096-01-01",
        date_to: "2096-12-31", // > 92 days
        include_weekends: true,
        project_id: null,
        category: "work",
        note: `${N}x`,
      },
      adminUserId,
    );
    expect("error" in tooLong && tooLong.status).toBe(400);

    const past = await bulkCreateEntries(
      {
        user_ids: [adminUserId],
        date_from: "2000-01-01",
        date_to: "2000-01-02",
        include_weekends: true,
        project_id: null,
        category: "work",
        note: `${N}x`,
      },
      adminUserId,
    );
    expect("error" in past && past.status).toBe(403);

    const badRange = await bulkCreateEntries(
      {
        user_ids: [adminUserId],
        date_from: "2096-06-10",
        date_to: "2096-06-01",
        include_weekends: true,
        project_id: null,
        category: "work",
        note: `${N}x`,
      },
      adminUserId,
    );
    expect("error" in badRange && badRange.status).toBe(400);

    const badCat = await bulkCreateEntries(
      {
        user_ids: [adminUserId],
        date_from: "2096-06-01",
        date_to: "2096-06-01",
        include_weekends: true,
        project_id: null,
        category: "__nope__",
        note: `${N}x`,
      },
      adminUserId,
    );
    expect("error" in badCat && badCat.status).toBe(400);
  });
});

// ---------------------------------------------------------------------------
// HTTP route tests (Fastify inject)
// ---------------------------------------------------------------------------

// Build a Fastify app with only the plan routes registered under
// /api/admin/plan. rateLimit max=1000 keeps the 7 tests from tripping it.
let app: Awaited<ReturnType<typeof buildApp>>;
let adminToken: string;
let noPermToken: string;
let noPermRoleId: number;

async function buildApp() {
  const a = Fastify({ logger: false });
  await a.register(cookie);
  await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
  a.addHook("onRequest", securityHeaders);
  await a.register(planRoutes, { prefix: "/api/admin/plan" });
  return a;
}

/**
 * Generate a JWT access token. `requireAuth` re-loads auth data from the
 * DB via `loadAuthData(payload.sub)`, so the `role` claim is ignored —
 * only the `sub` (user id) matters for these tests.
 */
function generateToken(user: {
  id: number;
  username: string;
  roleName: string | null;
}): string {
  return jwt.sign(
    { sub: user.id, username: user.username, role: user.roleName },
    config.jwt.secret,
    { expiresIn: "15m" },
  );
}

async function authGet(path: string, token: string) {
  return app.inject({
    method: "GET",
    url: path,
    headers: { Authorization: `Bearer ${token}` },
  });
}

async function authPost(path: string, token: string, body: unknown) {
  return app.inject({
    method: "POST",
    url: path,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    payload: body as object,
  });
}

async function authPatch(path: string, token: string, body: unknown) {
  return app.inject({
    method: "PATCH",
    url: path,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    payload: body as object,
  });
}

async function authDelete(path: string, token: string) {
  return app.inject({
    method: "DELETE",
    url: path,
    headers: { Authorization: `Bearer ${token}` },
  });
}

beforeAll(async () => {
  app = await buildApp();

  // Re-fetch the admin user with the roles relation so we can build a token.
  const admin = await prisma.users.findFirst({
    where: { roles: { name: "admin" } },
    include: { roles: true },
  });
  if (!admin) throw new Error("admin user not found in test setup");
  adminUserId = admin.id;
  adminToken = generateToken({
    id: admin.id,
    username: admin.username,
    roleName: admin.roles?.name ?? null,
  });

  // Create a no-permission role + user; the role has zero permissions so
  // any permission check returns 403.
  const stamp = Date.now();
  const noPermRole = await prisma.roles.create({
    data: {
      name: `noperm_plan_${stamp}`,
      display_name: "No Perm Plan Test",
    },
  });
  noPermRoleId = noPermRole.id;
  const noPermUser = await prisma.users.create({
    data: {
      username: `noperm_plan_${stamp}`,
      first_name: "No",
      last_name: "Perm",
      email: `noperm_plan_${stamp}@test.local`,
      password_hash:
        "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
      role_id: noPermRole.id,
      is_active: true,
    },
  });
  noPermUserId = noPermUser.id;
  noPermToken = generateToken({
    id: noPermUser.id,
    username: noPermUser.username,
    roleName: noPermRole.name,
  });
});

afterAll(async () => {
  if (app) await app.close();
  if (noPermUserId) {
    await prisma.users
      .deleteMany({ where: { id: noPermUserId } })
      .catch(() => {});
  }
  if (noPermRoleId) {
    await prisma.roles
      .deleteMany({ where: { id: noPermRoleId } })
      .catch(() => {});
  }
});

describe("HTTP /api/admin/plan", () => {
  it("GET /users requires auth", async () => {
    const res = await app.inject({
      method: "GET",
      url: "/api/admin/plan/users",
    });
    expect(res.statusCode).toBe(401);
  });

  it("GET /users returns plan users for an admin", async () => {
    const res = await authGet("/api/admin/plan/users", adminToken);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.success).toBe(true);
    expect(Array.isArray(body.data)).toBe(true);
  });

  it("GET /grid returns days, users, cells", async () => {
    // Seed an entry so the cell on 2097-06-01 has a known note.
    await createEntry(
      {
        user_id: adminUserId,
        date_from: "2097-06-01",
        date_to: "2097-06-05",
        category: "work",
        note: `${N}grid test`,
      },
      adminUserId,
      false,
    );
    const res = await authGet(
      "/api/admin/plan/grid?date_from=2097-06-01&date_to=2097-06-05",
      adminToken,
    );
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.data.users).toBeDefined();
    expect(body.data.cells[adminUserId]).toBeDefined();
    expect(body.data.cells[adminUserId]["2097-06-01"][0].note).toBe(
      `${N}grid test`,
    );
  });

  it("GET /grid rejects ranges > 92 days", async () => {
    const res = await authGet(
      "/api/admin/plan/grid?date_from=2097-01-01&date_to=2097-12-31",
      adminToken,
    );
    expect(res.statusCode).toBe(400);
  });

  it("POST /entries requires attendance.manage", async () => {
    const res = await authPost("/api/admin/plan/entries", noPermToken, {
      user_id: adminUserId,
      date_from: "2097-07-01",
      date_to: "2097-07-01",
      category: "work",
      note: `${N}forbidden`,
    });
    expect(res.statusCode).toBe(403);
  });

  it("POST /entries works for admin", async () => {
    const res = await authPost("/api/admin/plan/entries", adminToken, {
      user_id: adminUserId,
      date_from: "2097-08-01",
      date_to: "2097-08-01",
      category: "work",
      note: `${N}http create`,
    });
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.success).toBe(true);
    expect(body.data.note).toBe(`${N}http create`);
  });

  it("POST /entries accepts an empty note (note is optional)", async () => {
    const res = await authPost("/api/admin/plan/entries", adminToken, {
      user_id: adminUserId,
      date_from: "2097-08-05",
      date_to: "2097-08-05",
      category: "work",
      note: "",
    });
    expect(res.statusCode).toBe(201);
  });

  it("POST /entries rejects a past date without ?force=1", async () => {
    const res = await authPost("/api/admin/plan/entries", adminToken, {
      user_id: adminUserId,
      date_from: "2000-02-01",
      date_to: "2000-02-01",
      category: "work",
      note: `${N}past http`,
    });
    expect(res.statusCode).toBe(403);
  });

  it("POST /overrides with force=1 allows past date", async () => {
    const res = await authPost(
      "/api/admin/plan/overrides?force=1",
      adminToken,
      {
        user_id: adminUserId,
        shift_date: "2000-01-15",
        category: "leave",
        note: `${N}forced past`,
      },
    );
    expect(res.statusCode).toBe(201);
  });

  it("POST /entries/bulk requires attendance.manage", async () => {
    const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
      user_ids: [adminUserId],
      date_from: "2095-06-01",
      date_to: "2095-06-01",
      include_weekends: true,
      category: "work",
      note: `${N}bulk-forbidden`,
    });
    expect(res.statusCode).toBe(403);
  });

  it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
    const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
      user_ids: [adminUserId],
      date_from: "2095-06-05", // Sunday; weekends on → 1 day
      date_to: "2095-06-05",
      include_weekends: true,
      category: "work",
      note: `${N}bulk-http`,
    });
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.success).toBe(true);
    expect(body.data.created_days).toBe(1);
    expect(body.data.users).toBe(1);
  });
});
