import prisma from "../config/database";
import {
  buildPlanAuditDescription,
  planCategoryLabel as fallbackCategoryLabel,
} from "../utils/planAuditDescription";

/**
 * Resolve display names for an audit description: the employee's full name
 * and the project name (or null when there is no project). Used so audit-log
 * rows show "Jan Novák · … · Projekt ABC" instead of bare numeric ids.
 * Falls back to "#id" placeholders if a row was concurrently removed.
 */
async function resolvePlanLabels(
  userId: number,
  projectId: number | null,
): Promise<{ userName: string; projectName: string | null }> {
  const [user, project] = await Promise.all([
    prisma.users.findUnique({
      where: { id: userId },
      select: { first_name: true, last_name: true, username: true },
    }),
    projectId
      ? prisma.projects.findUnique({
          where: { id: projectId },
          select: { name: true, project_number: true },
        })
      : Promise.resolve(null),
  ]);

  const userName = user
    ? `${user.first_name} ${user.last_name}`.trim() || user.username
    : `uživatel #${userId}`;

  const projectName = project
    ? project.name?.trim() || project.project_number || `projekt #${projectId}`
    : null;

  return { userName, projectName };
}

/** Resolve a category key to its Czech label from the DB, falling back to
 * the built-in map then the raw key. */
async function resolveCategoryLabel(key: string): Promise<string> {
  const cat = await prisma.plan_categories.findUnique({
    where: { key },
    select: { label: true },
  });
  return cat?.label ?? fallbackCategoryLabel(key);
}

/**
 * Resolve everything an audit description needs (user name, project name,
 * category label) for a single mutation in ONE parallel batch instead of two
 * sequential awaits (resolvePlanLabels then resolveCategoryLabel). Same three
 * queries, but fired together so the audit lookup adds one round-trip, not two.
 * The produced description is byte-for-byte identical to the previous code.
 */
async function resolveAuditLabels(
  userId: number,
  projectId: number | null,
  category: string,
): Promise<{
  userName: string;
  projectName: string | null;
  categoryLabel: string;
}> {
  const [{ userName, projectName }, categoryLabel] = await Promise.all([
    resolvePlanLabels(userId, projectId),
    resolveCategoryLabel(category),
  ]);
  return { userName, projectName, categoryLabel };
}

/** Returns an error result if the category key is not an active category. */
async function assertActiveCategory(
  key: string,
): Promise<{ error: string; status: number } | null> {
  const cat = await prisma.plan_categories.findFirst({
    where: { key, is_active: true },
    select: { id: true },
  });
  if (!cat) return { error: "Neplatná nebo neaktivní kategorie", status: 400 };
  return null;
}

/** YYYY-MM-DD from a Prisma @db.Date value. Matches how the rest of this
 * file reads date-only columns (UTC slice), so it is timezone-stable. */
function isoDay(d: Date): string {
  return d.toISOString().slice(0, 10);
}

/** Prisma `select` for the joined project, plus a mapper to the cell's
 * project_number/project_name fields. Used so the grid carries the project
 * label without a second client-side lookup. */
const projectSelect = { select: { project_number: true, name: true } } as const;

function projectFields(
  p: { project_number: string | null; name: string | null } | null | undefined,
): { project_number: string | null; project_name: string | null } {
  return {
    project_number: p?.project_number ?? null,
    project_name: p?.name ?? null,
  };
}

/**
 * Resolved plan cell for a single (user, date). Either from a range entry
 * (work_plan_entries) or from a single-day override (work_plan_overrides).
 */
export interface ResolvedCell {
  /** Where the cell came from. */
  source: "entry" | "override";
  /** ID of the work_plan_entries row, or null when source is "override". */
  entryId: number | null;
  /** ID of the work_plan_overrides row, or null when source is "entry". */
  overrideId: number | null;
  user_id: number;
  /** The date the cell resolves to (echoes the input, YYYY-MM-DD). */
  shift_date: string;
  project_id: number | null;
  /** Project number/name resolved server-side from the projects relation,
   * so the UI never depends on a separately-fetched (and capped/permission-
   * gated) projects list to display the project. Null when no project. */
  project_number: string | null;
  project_name: string | null;
  category: string;
  note: string;
  /** Original entry range, or null when source is "override". */
  rangeFrom: string | null;
  rangeTo: string | null;
}

/**
 * Compute the effective plan records for (userId, dateStr) as an array of
 * 0–MAX_RECORDS_PER_CELL records.
 *
 * Layering: if any override exists for the day, the overrides are returned
 * (newest-first) and the range entries are hidden; otherwise the range
 * entries covering the day are returned (newest-first). Soft-deleted rows
 * are ignored (is_deleted = false filter). The result is capped at
 * MAX_RECORDS_PER_CELL.
 *
 * dateStr must be a YYYY-MM-DD string. The function builds a JS Date
 * from it and Prisma handles timezone conversion against the MySQL
 * @db.Date columns.
 */
export async function resolveCell(
  userId: number,
  dateStr: string,
): Promise<ResolvedCell[]> {
  const date = new Date(dateStr);

  const overrides = await prisma.work_plan_overrides.findMany({
    where: { user_id: userId, shift_date: date, is_deleted: false },
    // created_at is second-precision, so same-second rows tie — `id` desc is
    // the deterministic "newest-first" tiebreak (matches insertion order).
    orderBy: [{ created_at: "desc" }, { id: "desc" }],
    take: MAX_RECORDS_PER_CELL,
    include: { projects: projectSelect },
  });
  if (overrides.length > 0) {
    return overrides.map((o) => ({
      source: "override" as const,
      entryId: null,
      overrideId: o.id,
      user_id: o.user_id,
      shift_date: dateStr,
      project_id: o.project_id,
      ...projectFields(o.projects),
      category: o.category,
      note: o.note,
      rangeFrom: null,
      rangeTo: null,
    }));
  }

  const entries = await prisma.work_plan_entries.findMany({
    where: {
      user_id: userId,
      date_from: { lte: date },
      date_to: { gte: date },
      is_deleted: false,
    },
    // created_at is second-precision, so same-second rows tie — `id` desc is
    // the deterministic "newest-first" tiebreak (matches insertion order).
    orderBy: [{ created_at: "desc" }, { id: "desc" }],
    take: MAX_RECORDS_PER_CELL,
    include: { projects: projectSelect },
  });
  return entries.map((entry) => ({
    source: "entry" as const,
    entryId: entry.id,
    overrideId: null,
    user_id: entry.user_id,
    shift_date: dateStr,
    project_id: entry.project_id,
    ...projectFields(entry.projects),
    category: entry.category,
    note: entry.note,
    rangeFrom: entry.date_from.toISOString().slice(0, 10),
    rangeTo: entry.date_to.toISOString().slice(0, 10),
  }));
}

/**
 * Compute the effective plan records for every (userId, date) in the
 * given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell[]
 * (0–MAX_RECORDS_PER_CELL records, same layering as resolveCell; empty
 * days are []).
 *
 * Implementation: load all entries and overrides for the range in two
 * queries, then iterate the dates and resolve each cell in O(1) per
 * (user, date). This avoids N+1 calls to resolveCell.
 */
export async function resolveGrid(
  userIds: number[],
  dateFromStr: string,
  dateToStr: string,
): Promise<Record<number, Record<string, ResolvedCell[]>>> {
  const dateFrom = new Date(dateFromStr);
  const dateTo = new Date(dateToStr);

  const [entries, overrides] = await Promise.all([
    prisma.work_plan_entries.findMany({
      where: {
        user_id: { in: userIds },
        is_deleted: false,
        date_from: { lte: dateTo },
        date_to: { gte: dateFrom },
      },
      // created_at is second-precision, so same-second rows tie — `id` desc is
      // the deterministic "newest-first" tiebreak (matches insertion order).
      orderBy: [{ created_at: "desc" }, { id: "desc" }],
      include: { projects: projectSelect },
    }),
    prisma.work_plan_overrides.findMany({
      where: {
        user_id: { in: userIds },
        is_deleted: false,
        shift_date: { gte: dateFrom, lte: dateTo },
      },
      // created_at is second-precision, so same-second rows tie — `id` desc is
      // the deterministic "newest-first" tiebreak (matches insertion order).
      orderBy: [{ created_at: "desc" }, { id: "desc" }],
      include: { projects: projectSelect },
    }),
  ]);

  const dates: string[] = [];
  for (
    let d = new Date(dateFrom);
    d <= dateTo;
    d.setUTCDate(d.getUTCDate() + 1)
  ) {
    dates.push(d.toISOString().slice(0, 10));
  }

  const result: Record<number, Record<string, ResolvedCell[]>> = {};
  for (const uid of userIds) {
    result[uid] = {};
    for (const dateStr of dates) {
      const day = new Date(dateStr);
      const dayOverrides = overrides
        .filter(
          (o) =>
            o.user_id === uid &&
            o.shift_date.toISOString().slice(0, 10) === dateStr,
        )
        .slice(0, MAX_RECORDS_PER_CELL);
      if (dayOverrides.length > 0) {
        result[uid][dateStr] = dayOverrides.map((o) => ({
          source: "override" as const,
          entryId: null,
          overrideId: o.id,
          user_id: o.user_id,
          shift_date: dateStr,
          project_id: o.project_id,
          ...projectFields(o.projects),
          category: o.category,
          note: o.note,
          rangeFrom: null,
          rangeTo: null,
        }));
        continue;
      }
      const dayEntries = entries
        .filter(
          (e) => e.user_id === uid && e.date_from <= day && e.date_to >= day,
        )
        .slice(0, MAX_RECORDS_PER_CELL);
      result[uid][dateStr] = dayEntries.map((e) => ({
        source: "entry" as const,
        entryId: e.id,
        overrideId: null,
        user_id: e.user_id,
        shift_date: dateStr,
        project_id: e.project_id,
        ...projectFields(e.projects),
        category: e.category,
        note: e.note,
        rangeFrom: e.date_from.toISOString().slice(0, 10),
        rangeTo: e.date_to.toISOString().slice(0, 10),
      }));
    }
  }

  return result;
}

export interface PlanUser {
  id: number;
  full_name: string;
  username: string;
  role_name: string | null;
  is_active: boolean;
  has_attendance_record: boolean;
}

/**
 * Return users that should appear as columns in the plan grid:
 * everyone with the `attendance.record` permission, ordered by
 * role/team name then full_name. Admins are included by default.
 *
 * The `users` model uses the relation field name `roles` (NOT `role`)
 * for the foreign key to `roles.id`. The roles-to-permissions join
 * table is `role_permissions`, which has a relation `permissions`.
 */
export async function listPlanUsers(): Promise<PlanUser[]> {
  const rows = await prisma.users.findMany({
    where: {
      is_active: true,
      roles: {
        role_permissions: {
          some: { permissions: { name: "attendance.record" } },
        },
      },
    },
    include: {
      roles: {
        select: { name: true },
      },
    },
    orderBy: [
      { roles: { name: "asc" } },
      { last_name: "asc" },
      { first_name: "asc" },
    ],
  });

  return rows.map((u) => ({
    id: u.id,
    full_name: `${u.first_name} ${u.last_name}`.trim(),
    username: u.username,
    role_name: u.roles?.name ?? null,
    is_active: u.is_active ?? true,
    has_attendance_record: true,
  }));
}

/**
 * Guard helper: returns { error, status } if `dateStr` is in the past
 * and `force` is not set. Returns null if the date is editable.
 *
 * The comparison uses local Czech time (the project's TZ setting). The
 * previous implementation used `toISOString().slice(0, 10)`, which is
 * UTC — that could be off-by-one near day boundaries. The fix mirrors
 * the `Date.prototype.toJSON` override in `src/config/env.ts` which
 * also uses local-time getters, so the past-date gate is consistent
 * with how dates are formatted throughout the rest of the codebase.
 */
export function assertNotPastDate(
  dateStr: string,
  force: boolean,
): { error: string; status: number } | null {
  if (force) return null;

  const d = new Date();
  const todayStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  if (dateStr < todayStr) {
    return {
      error:
        "Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.",
      status: 403,
    };
  }
  return null;
}

// ---------------------------------------------------------------------------
// Entry CRUD
// ---------------------------------------------------------------------------

/**
 * Standard service result envelope. On success returns `{ data, oldData }`
 * where `oldData` is null for creates and the pre-update snapshot for
 * updates / deletes (so the route handler can call `logAudit` with it).
 *
 * On failure returns `{ error, status }`.
 */
export type Result<T> =
  | {
      data: T;
      oldData: unknown | null;
      /** Human-readable Czech subject for the audit-log row. */
      description?: string;
    }
  | { error: string; status: number };

/** Convert a YYYY-MM-DD string to a Date at midnight UTC. */
function toDateOnly(dateStr: string): Date {
  return new Date(dateStr + "T00:00:00.000Z");
}

/** Maximum number of records (entries OR overrides) shown per cell per day. */
export const MAX_RECORDS_PER_CELL = 3;

/**
 * Returns { error, status } if creating an entry over [dateFromStr, dateToStr]
 * would push any single day past MAX_RECORDS_PER_CELL active entries for the
 * user. Names the first offending date. Counts entries regardless of whether an
 * override currently hides them (simpler and predictable).
 */
async function assertEntryCapAvailable(
  userId: number,
  dateFromStr: string,
  dateToStr: string,
): Promise<{ error: string; status: number } | null> {
  const from = toDateOnly(dateFromStr);
  const to = toDateOnly(dateToStr);
  const existing = await prisma.work_plan_entries.findMany({
    where: {
      user_id: userId,
      is_deleted: false,
      date_from: { lte: to },
      date_to: { gte: from },
    },
    select: { date_from: true, date_to: true },
  });
  for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
    const day = new Date(d);
    const count = existing.filter(
      (e) => e.date_from <= day && e.date_to >= day,
    ).length;
    if (count >= MAX_RECORDS_PER_CELL) {
      return {
        error: `Na den ${day.toISOString().slice(0, 10)} jsou již ${MAX_RECORDS_PER_CELL} záznamy (maximum).`,
        status: 400,
      };
    }
  }
  return null;
}

type CreatedEntry = {
  id: number;
  user_id: number;
  date_from: Date;
  date_to: Date;
  project_id: number | null;
  category: string;
  note: string;
  created_by: number;
  created_at: Date | null;
  updated_at: Date | null;
  is_deleted: boolean | null;
};

/**
 * Core create logic shared by the public createEntry and bulkCreateEntries.
 * `resolveLabels` controls the audit-description lookup: the single-mutation
 * route needs it, but bulkCreateEntries discards the per-entry description (it
 * builds its own summary), so it skips the 3 label queries per run entirely —
 * removing the per-run audit N+1.
 */
async function createEntryCore(
  input: {
    user_id: number;
    date_from: string;
    date_to: string;
    project_id?: number | null;
    category: string;
    note: string;
  },
  actorUserId: number,
  force: boolean,
  resolveLabels: boolean,
): Promise<Result<CreatedEntry>> {
  // Range check
  if (input.date_to < input.date_from) {
    return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
  }
  // Past-date lock on both endpoints — a range that starts today but
  // extends into the past would still lock past days in the grid.
  const lockFrom = assertNotPastDate(input.date_from, force);
  if (lockFrom) return lockFrom;
  const lockTo = assertNotPastDate(input.date_to, force);
  if (lockTo) return lockTo;

  const catErr = await assertActiveCategory(input.category);
  if (catErr) return catErr;

  const capErr = await assertEntryCapAvailable(
    input.user_id,
    input.date_from,
    input.date_to,
  );
  if (capErr) return capErr;

  const created = await prisma.work_plan_entries.create({
    data: {
      user_id: input.user_id,
      date_from: toDateOnly(input.date_from),
      date_to: toDateOnly(input.date_to),
      project_id: input.project_id ?? null,
      category: input.category,
      note: input.note,
      created_by: actorUserId,
    },
  });

  if (!resolveLabels) {
    return { data: created, oldData: null };
  }

  const { userName, projectName, categoryLabel } = await resolveAuditLabels(
    created.user_id,
    created.project_id,
    created.category,
  );
  const description = buildPlanAuditDescription({
    userName,
    categoryLabel,
    projectName,
    dateFrom: input.date_from,
    dateTo: input.date_to,
    force,
  });

  return { data: created, oldData: null, description };
}

/** Create a work_plan_entries row. Past dates require force=true. */
export async function createEntry(
  input: {
    user_id: number;
    date_from: string;
    date_to: string;
    project_id?: number | null;
    category: string;
    note: string;
  },
  actorUserId: number,
  force: boolean,
): Promise<Result<CreatedEntry>> {
  return createEntryCore(input, actorUserId, force, true);
}

/** Update a work_plan_entries row. Partial update. */
export async function updateEntry(
  id: number,
  input: {
    date_from?: string;
    date_to?: string;
    project_id?: number | null;
    category?: string;
    note?: string;
  },
  actorUserId: number,
  force: boolean,
): Promise<Result<unknown>> {
  const existing = await prisma.work_plan_entries.findUnique({ where: { id } });
  if (!existing || existing.is_deleted) {
    return { error: "Záznam nenalezen", status: 404 };
  }

  // Past-date lock on both endpoints (using either the new value if
  // provided, or the existing value as a fallback).
  const fromStr =
    input.date_from ?? existing.date_from.toISOString().slice(0, 10);
  const toStr = input.date_to ?? existing.date_to.toISOString().slice(0, 10);
  const lockFrom = assertNotPastDate(fromStr, force);
  if (lockFrom) return lockFrom;
  const lockTo = assertNotPastDate(toStr, force);
  if (lockTo) return lockTo;

  if (input.date_from && input.date_to && input.date_to < input.date_from) {
    return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
  }

  // Only validate the category when it actually changes — so editing just the
  // note of an entry whose category was later retired/hidden still saves.
  if (input.category && input.category !== existing.category) {
    const catErr = await assertActiveCategory(input.category);
    if (catErr) return catErr;
  }

  const updated = await prisma.work_plan_entries.update({
    where: { id },
    data: {
      date_from: input.date_from ? toDateOnly(input.date_from) : undefined,
      date_to: input.date_to ? toDateOnly(input.date_to) : undefined,
      project_id: input.project_id === undefined ? undefined : input.project_id,
      category: input.category,
      note: input.note,
    },
  });

  const { userName, projectName, categoryLabel } = await resolveAuditLabels(
    updated.user_id,
    updated.project_id,
    updated.category,
  );
  const description = buildPlanAuditDescription({
    userName,
    categoryLabel,
    projectName,
    dateFrom: isoDay(updated.date_from),
    dateTo: isoDay(updated.date_to),
    force,
  });

  return { data: updated, oldData: existing, description };
}

/** Soft-delete a work_plan_entries row. */
export async function deleteEntry(
  id: number,
  actorUserId: number,
  force: boolean,
): Promise<Result<{ ok: true }>> {
  const existing = await prisma.work_plan_entries.findUnique({ where: { id } });
  if (!existing || existing.is_deleted) {
    return { error: "Záznam nenalezen", status: 404 };
  }
  const fromStr = existing.date_from.toISOString().slice(0, 10);
  const lock = assertNotPastDate(fromStr, force);
  if (lock) return lock;

  await prisma.work_plan_entries.update({
    where: { id },
    data: { is_deleted: true },
  });

  const { userName, projectName, categoryLabel } = await resolveAuditLabels(
    existing.user_id,
    existing.project_id,
    existing.category,
  );
  const description = buildPlanAuditDescription({
    userName,
    categoryLabel,
    projectName,
    dateFrom: isoDay(existing.date_from),
    dateTo: isoDay(existing.date_to),
    force,
  });

  return { data: { ok: true }, oldData: existing, description };
}

// ---------------------------------------------------------------------------
// Override CRUD
// ---------------------------------------------------------------------------

/**
 * Create a work_plan_overrides row. Additive: stacks up to
 * MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
 * past the cap returns { error, status: 400 }. Past dates require force=true.
 * Returns { data, oldData: null, description }.
 */
export async function createOverride(
  input: {
    user_id: number;
    shift_date: string;
    project_id?: number | null;
    category: string;
    note: string;
  },
  actorUserId: number,
  force: boolean,
): Promise<Result<any>> {
  const lock = assertNotPastDate(input.shift_date, force);
  if (lock) return lock;

  const catErr = await assertActiveCategory(input.category);
  if (catErr) return catErr;

  const date = toDateOnly(input.shift_date);

  // Additive up to MAX_RECORDS_PER_CELL. (This previously REPLACED the existing
  // override; multi-record cells stack instead.) count+create runs in one
  // transaction; a concurrent create could in theory add one extra row, but
  // resolve display caps at MAX_RECORDS_PER_CELL so that is benign.
  let created;
  try {
    created = await prisma.$transaction(async (tx) => {
      const count = await tx.work_plan_overrides.count({
        where: { user_id: input.user_id, shift_date: date, is_deleted: false },
      });
      if (count >= MAX_RECORDS_PER_CELL) {
        throw Object.assign(new Error("cap"), { __cap: true });
      }
      return tx.work_plan_overrides.create({
        data: {
          user_id: input.user_id,
          shift_date: date,
          project_id: input.project_id ?? null,
          category: input.category,
          note: input.note,
          created_by: actorUserId,
        },
      });
    });
  } catch (e) {
    if (e && typeof e === "object" && "__cap" in e) {
      return {
        error: `Na tento den jsou již ${MAX_RECORDS_PER_CELL} záznamy (maximum).`,
        status: 400,
      };
    }
    throw e;
  }

  const { userName, projectName, categoryLabel } = await resolveAuditLabels(
    created.user_id,
    created.project_id,
    created.category,
  );
  const description = buildPlanAuditDescription({
    userName,
    categoryLabel,
    projectName,
    dateFrom: input.shift_date,
    dateTo: input.shift_date,
    force,
  });

  return { data: created, oldData: null, description };
}

/** Update a work_plan_overrides row. */
export async function updateOverride(
  id: number,
  input: { project_id?: number | null; category?: string; note?: string },
  actorUserId: number,
  force: boolean,
): Promise<Result<any>> {
  const existing = await prisma.work_plan_overrides.findUnique({
    where: { id },
  });
  if (!existing || existing.is_deleted) {
    return { error: "Záznam nenalezen", status: 404 };
  }
  const dateStr = existing.shift_date.toISOString().slice(0, 10);
  const lock = assertNotPastDate(dateStr, force);
  if (lock) return lock;

  // Only validate the category when it actually changes (see updateEntry).
  if (input.category && input.category !== existing.category) {
    const catErr = await assertActiveCategory(input.category);
    if (catErr) return catErr;
  }

  const updated = await prisma.work_plan_overrides.update({
    where: { id },
    data: {
      project_id: input.project_id === undefined ? undefined : input.project_id,
      category: input.category,
      note: input.note,
    },
  });

  const { userName, projectName, categoryLabel } = await resolveAuditLabels(
    updated.user_id,
    updated.project_id,
    updated.category,
  );
  const description = buildPlanAuditDescription({
    userName,
    categoryLabel,
    projectName,
    dateFrom: isoDay(updated.shift_date),
    dateTo: isoDay(updated.shift_date),
    force,
  });

  return { data: updated, oldData: existing, description };
}

/** Soft-delete a work_plan_overrides row. */
export async function deleteOverride(
  id: number,
  actorUserId: number,
  force: boolean,
): Promise<Result<{ ok: true }>> {
  const existing = await prisma.work_plan_overrides.findUnique({
    where: { id },
  });
  if (!existing || existing.is_deleted) {
    return { error: "Záznam nenalezen", status: 404 };
  }
  const dateStr = existing.shift_date.toISOString().slice(0, 10);
  const lock = assertNotPastDate(dateStr, force);
  if (lock) return lock;

  await prisma.work_plan_overrides.update({
    where: { id },
    data: { is_deleted: true },
  });

  const { userName, projectName, categoryLabel } = await resolveAuditLabels(
    existing.user_id,
    existing.project_id,
    existing.category,
  );
  const description = buildPlanAuditDescription({
    userName,
    categoryLabel,
    projectName,
    dateFrom: isoDay(existing.shift_date),
    dateTo: isoDay(existing.shift_date),
    force,
  });

  return { data: { ok: true }, oldData: existing, description };
}

// ---------------------------------------------------------------------------
// Bulk entry creation
// ---------------------------------------------------------------------------

/**
 * Bulk-assign a project/category to many employees over a date range. For each
 * employee, walk the range, keep the days that pass the weekday filter
 * (weekends excluded unless include_weekends) AND are under the per-cell cap,
 * group contiguous kept days into runs, and create one range entry per run by
 * reusing createEntry (so cap / past-date / category validation stays DRY).
 *
 * Days that pass the weekday filter but are already at the cap are counted in
 * skipped_days (and split the range). Weekend days, when excluded, are out of
 * scope and are NOT counted as skipped.
 */
export async function bulkCreateEntries(
  input: {
    user_ids: number[];
    date_from: string;
    date_to: string;
    include_weekends: boolean;
    project_id?: number | null;
    category: string;
    note: string;
  },
  actorUserId: number,
): Promise<
  Result<{
    created_entries: number;
    created_days: number;
    skipped_days: number;
    users: number;
  }>
> {
  if (input.user_ids.length === 0) {
    return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
  }
  if (input.date_to < input.date_from) {
    return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
  }
  const lockFrom = assertNotPastDate(input.date_from, false);
  if (lockFrom) return lockFrom;
  const lockTo = assertNotPastDate(input.date_to, false);
  if (lockTo) return lockTo;
  const catErr = await assertActiveCategory(input.category);
  if (catErr) return catErr;

  const from = toDateOnly(input.date_from);
  const to = toDateOnly(input.date_to);

  // Bound the work (users × days). Mirror the 92-day cap on GET /plan/grid so
  // one bulk call can't fan out into thousands of inserts.
  const rangeDays = Math.round((to.getTime() - from.getTime()) / 86400000) + 1;
  if (rangeDays > 92) {
    return { error: "Maximální rozsah je 92 dní", status: 400 };
  }

  // The day list for the whole range (UTC midnights), built once.
  const days: Date[] = [];
  for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
    days.push(new Date(d));
  }

  let createdEntries = 0;
  let createdDays = 0;
  let skippedDays = 0;

  for (const userId of input.user_ids) {
    // Per-day cap counts from this user's existing active entries.
    const existing = await prisma.work_plan_entries.findMany({
      where: {
        user_id: userId,
        is_deleted: false,
        date_from: { lte: to },
        date_to: { gte: from },
      },
      select: { date_from: true, date_to: true },
    });
    const countFor = (day: Date): number =>
      existing.filter((e) => e.date_from <= day && e.date_to >= day).length;

    // Eligibility per day (weekday filter + under-cap). Tally cap skips.
    const eligible: boolean[] = [];
    for (const day of days) {
      const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
      const isWeekend = dow === 0 || dow === 6;
      if (isWeekend && !input.include_weekends) {
        eligible.push(false);
        continue;
      }
      if (countFor(day) >= MAX_RECORDS_PER_CELL) {
        skippedDays++;
        eligible.push(false);
        continue;
      }
      eligible.push(true);
    }

    // Group contiguous eligible days into runs; create one range per run.
    let runStart = -1;
    for (let i = 0; i <= days.length; i++) {
      const ok = i < days.length && eligible[i];
      if (ok && runStart === -1) {
        runStart = i;
      } else if (!ok && runStart !== -1) {
        const segFrom = days[runStart].toISOString().slice(0, 10);
        const segTo = days[i - 1].toISOString().slice(0, 10);
        const runDays = i - runStart;
        // Skip audit-label resolution: bulk builds its own summary and discards
        // each entry's description, so resolving labels per run would be pure
        // wasted queries (the per-run audit N+1 the audit flagged).
        const res = await createEntryCore(
          {
            user_id: userId,
            date_from: segFrom,
            date_to: segTo,
            project_id: input.project_id ?? null,
            category: input.category,
            note: input.note,
          },
          actorUserId,
          false,
          false,
        );
        if ("data" in res) {
          createdEntries++;
          createdDays += runDays;
        } else {
          // Rare race (e.g. the cap filled concurrently between the count and
          // the create). Treat the run's days as skipped, but log — services
          // must not swallow non-fatal failures (CLAUDE.md known-issue #4).
          console.warn("[plan.service] bulkCreateEntries: segment skipped", {
            userId,
            segFrom,
            segTo,
            error: res.error,
          });
          skippedDays += runDays;
        }
        runStart = -1;
      }
    }
  }

  return {
    data: {
      created_entries: createdEntries,
      created_days: createdDays,
      skipped_days: skippedDays,
      users: input.user_ids.length,
    },
    oldData: null,
  };
}

// ---------------------------------------------------------------------------
// Raw-row list endpoints
// ---------------------------------------------------------------------------

/** List raw work_plan_entries rows in a date range, excluding soft-deleted.
 * When isAdmin is false, the user_id filter is forced to the actor's own id
 * (so a non-admin cannot read another user's plan via the user_id query param). */
export async function listEntries(
  query: { user_id?: number; date_from: string; date_to: string },
  actorUserId: number,
  isAdmin: boolean,
) {
  const effectiveUserId = isAdmin ? query.user_id : actorUserId;
  return prisma.work_plan_entries.findMany({
    where: {
      user_id: effectiveUserId,
      is_deleted: false,
      date_from: { lte: toDateOnly(query.date_to) },
      date_to: { gte: toDateOnly(query.date_from) },
    },
    orderBy: [{ date_from: "asc" }, { id: "asc" }],
  });
}

/** List raw work_plan_overrides rows in a date range, excluding soft-deleted.
 * Same employee-scoping rules as listEntries. */
export async function listOverrides(
  query: { user_id?: number; date_from: string; date_to: string },
  actorUserId: number,
  isAdmin: boolean,
) {
  const effectiveUserId = isAdmin ? query.user_id : actorUserId;
  return prisma.work_plan_overrides.findMany({
    where: {
      user_id: effectiveUserId,
      is_deleted: false,
      shift_date: {
        gte: toDateOnly(query.date_from),
        lte: toDateOnly(query.date_to),
      },
    },
    orderBy: [{ shift_date: "asc" }, { id: "asc" }],
  });
}
