/* ════════════════════════════════════════════════════════════════════════
   MSINDAHA Operations — CORE
   Config, icons, timezone-safe date helpers, Firestore hooks, the
   assignment/permission model and the notification senders.

   This is the first of the js/app/*.jsx files. They are compiled in the
   browser by Babel (react preset only) and run as classic scripts in order,
   so every top-level declaration here is visible to the files after it.
   index.html and audit.html load the exact same files — the only thing that
   differs between the two dashboards is window.APP_CONFIG.

   Because all files share one global scope, never use object-rest
   destructuring (`const { a, ...rest } = obj`): Babel emits a top-level
   `const _excluded` helper for it in every file, and the second file to
   declare it fails to load. Use omit() below instead.
════════════════════════════════════════════════════════════════════════ */

const CFG = {
  department: "payroll",
  deptLabel: "Payroll",
  features: { website: false, hr: false },
  dashboards: { payroll: "index.html", audit: "audit.html" },
  // Set on dashboards that don't host the login form themselves.
  loginUrl: null,
  ticketCategories: [
    "General",
    "Payroll",
    "Benefits",
    "Compliance",
    "Access",
    "HR",
    "Finance",
  ],
  // 0 = Sunday. Used by the calendars and "this week" ranges.
  weekStart: 0,
  version: "2.0",
  ...(window.APP_CONFIG || {}),
};
const MY_DEPARTMENT = CFG.department;

/* ─── Departments — the built-in pair plus anything a Super Admin adds to
   the `departments` collection (merged in place by useDepartments). ──── */
const DEPARTMENTS = [
  { id: "payroll", label: "Payroll" },
  { id: "audit", label: "Audit" },
];
const deptLabelOf = (id) =>
  DEPARTMENTS.find((d) => d.id === id)?.label ?? id ?? "—";

/* ═══════════════════════════ ICONS ═══════════════════════════ */
function Svg(props) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={props.sw ?? 2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      {props.children}
    </svg>
  );
}

const IC = {
  Home: () => (
    <Svg>
      <path d="M3 9.5 12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1Z" />
    </Svg>
  ),
  Mail: () => (
    <Svg>
      <rect width="20" height="16" x="2" y="4" rx="2" />
      <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
    </Svg>
  ),
  Ticket: () => (
    <Svg>
      <path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z" />
      <path d="M13 5v2" />
      <path d="M13 17v2" />
      <path d="M13 11v2" />
    </Svg>
  ),
  Blog: () => (
    <Svg>
      <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 0-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
      <path d="M18 14h-8" />
      <path d="M15 18h-5" />
      <path d="M10 6h8v4h-8V6Z" />
    </Svg>
  ),
  Users: () => (
    <Svg>
      <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
      <circle cx="9" cy="7" r="4" />
      <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
      <path d="M16 3.13a4 4 0 0 1 0 7.75" />
    </Svg>
  ),
  User: () => (
    <Svg>
      <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
      <circle cx="12" cy="7" r="4" />
    </Svg>
  ),
  UserPlus: () => (
    <Svg>
      <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
      <circle cx="9" cy="7" r="4" />
      <line x1="19" y1="8" x2="19" y2="14" />
      <line x1="22" y1="11" x2="16" y2="11" />
    </Svg>
  ),
  Tasks: () => (
    <Svg>
      <rect width="8" height="4" x="8" y="2" rx="1" />
      <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
      <path d="m9 14 2 2 4-4" />
    </Svg>
  ),
  Plus: () => (
    <Svg sw={2.4}>
      <line x1="12" y1="5" x2="12" y2="19" />
      <line x1="5" y1="12" x2="19" y2="12" />
    </Svg>
  ),
  X: () => (
    <Svg sw={2.4}>
      <line x1="18" y1="6" x2="6" y2="18" />
      <line x1="6" y1="6" x2="18" y2="18" />
    </Svg>
  ),
  Edit: () => (
    <Svg>
      <path d="M12 20h9" />
      <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
    </Svg>
  ),
  Trash: () => (
    <Svg>
      <polyline points="3 6 5 6 21 6" />
      <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
    </Svg>
  ),
  Check: () => (
    <Svg sw={2.8}>
      <polyline points="20 6 9 17 4 12" />
    </Svg>
  ),
  CheckCheck: () => (
    <Svg>
      <path d="M18 6 7 17l-5-5" />
      <path d="m22 10-7.5 7.5L13 16" />
    </Svg>
  ),
  CheckCircle: () => (
    <Svg>
      <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
      <polyline points="22 4 12 14.01 9 11.01" />
    </Svg>
  ),
  Info: () => (
    <Svg>
      <circle cx="12" cy="12" r="10" />
      <line x1="12" y1="16" x2="12" y2="12" />
      <line x1="12" y1="8" x2="12.01" y2="8" />
    </Svg>
  ),
  Note: () => (
    <Svg>
      <path d="M15.5 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z" />
      <path d="M15 3v6h6" />
      <line x1="8" y1="13" x2="16" y2="13" />
      <line x1="8" y1="17" x2="12" y2="17" />
    </Svg>
  ),
  Link: () => (
    <Svg>
      <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
      <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
    </Svg>
  ),
  External: () => (
    <Svg>
      <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
      <polyline points="15 3 21 3 21 9" />
      <line x1="10" y1="14" x2="21" y2="3" />
    </Svg>
  ),
  LogOut: () => (
    <Svg>
      <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
      <polyline points="16 17 21 12 16 7" />
      <line x1="21" y1="12" x2="9" y2="12" />
    </Svg>
  ),
  Comment: () => (
    <Svg>
      <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
    </Svg>
  ),
  Lock: () => (
    <Svg>
      <rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
      <path d="M7 11V7a5 5 0 0 1 10 0v4" />
    </Svg>
  ),
  Globe: () => (
    <Svg>
      <circle cx="12" cy="12" r="10" />
      <path d="M2 12h20" />
      <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
    </Svg>
  ),
  Search: () => (
    <Svg>
      <circle cx="11" cy="11" r="8" />
      <path d="m21 21-4.3-4.3" />
    </Svg>
  ),
  Download: () => (
    <Svg>
      <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
      <polyline points="7 10 12 15 17 10" />
      <line x1="12" y1="15" x2="12" y2="3" />
    </Svg>
  ),
  Calendar: () => (
    <Svg>
      <rect width="18" height="18" x="3" y="4" rx="2" />
      <line x1="16" y1="2" x2="16" y2="6" />
      <line x1="8" y1="2" x2="8" y2="6" />
      <line x1="3" y1="10" x2="21" y2="10" />
    </Svg>
  ),
  Target: () => (
    <Svg>
      <circle cx="12" cy="12" r="10" />
      <circle cx="12" cy="12" r="6" />
      <circle cx="12" cy="12" r="2" />
    </Svg>
  ),
  Alert: () => (
    <Svg>
      <path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
      <line x1="12" y1="9" x2="12" y2="13" />
      <line x1="12" y1="17" x2="12.01" y2="17" />
    </Svg>
  ),
  Activity: () => (
    <Svg>
      <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
    </Svg>
  ),
  Bell: () => (
    <Svg>
      <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
      <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
    </Svg>
  ),
  BellOff: () => (
    <Svg>
      <path d="M13.73 21a1.94 1.94 0 0 1-3.4 0" />
      <path d="M18.63 13A17.89 17.89 0 0 1 18 8" />
      <path d="M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14" />
      <path d="M18 8a6 6 0 0 0-9.33-5" />
      <line x1="1" y1="1" x2="23" y2="23" />
    </Svg>
  ),
  ChevronLeft: () => (
    <Svg sw={2.4}>
      <polyline points="15 18 9 12 15 6" />
    </Svg>
  ),
  ChevronRight: () => (
    <Svg sw={2.4}>
      <polyline points="9 18 15 12 9 6" />
    </Svg>
  ),
  ChevronDown: () => (
    <Svg sw={2.4}>
      <polyline points="6 9 12 15 18 9" />
    </Svg>
  ),
  ChevronUp: () => (
    <Svg sw={2.4}>
      <polyline points="18 15 12 9 6 15" />
    </Svg>
  ),
  ArrowRight: () => (
    <Svg>
      <path d="M5 12h14" />
      <path d="m12 5 7 7-7 7" />
    </Svg>
  ),
  ArrowUpDown: () => (
    <Svg>
      <path d="m21 16-4 4-4-4" />
      <path d="M17 20V4" />
      <path d="m3 8 4-4 4 4" />
      <path d="M7 4v16" />
    </Svg>
  ),
  List: () => (
    <Svg>
      <line x1="8" y1="6" x2="21" y2="6" />
      <line x1="8" y1="12" x2="21" y2="12" />
      <line x1="8" y1="18" x2="21" y2="18" />
      <line x1="3" y1="6" x2="3.01" y2="6" />
      <line x1="3" y1="12" x2="3.01" y2="12" />
      <line x1="3" y1="18" x2="3.01" y2="18" />
    </Svg>
  ),
  Board: () => (
    <Svg>
      <rect width="18" height="18" x="3" y="3" rx="2" />
      <path d="M9 3v18" />
      <path d="M15 3v18" />
    </Svg>
  ),
  Filter: () => (
    <Svg>
      <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
    </Svg>
  ),
  Clock: () => (
    <Svg>
      <circle cx="12" cy="12" r="10" />
      <polyline points="12 6 12 12 16 14" />
    </Svg>
  ),
  Flag: () => (
    <Svg>
      <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
      <line x1="4" y1="22" x2="4" y2="15" />
    </Svg>
  ),
  Tag: () => (
    <Svg>
      <path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z" />
      <path d="M7 7h.01" />
    </Svg>
  ),
  Layers: () => (
    <Svg>
      <polygon points="12 2 2 7 12 12 22 7 12 2" />
      <polyline points="2 17 12 22 22 17" />
      <polyline points="2 12 12 17 22 12" />
    </Svg>
  ),
  Copy: () => (
    <Svg>
      <rect width="14" height="14" x="8" y="8" rx="2" />
      <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
    </Svg>
  ),
  More: () => (
    <Svg sw={2.6}>
      <circle cx="12" cy="12" r="1" />
      <circle cx="19" cy="12" r="1" />
      <circle cx="5" cy="12" r="1" />
    </Svg>
  ),
  Send: () => (
    <Svg>
      <path d="m22 2-7 20-4-9-9-4Z" />
      <path d="M22 2 11 13" />
    </Svg>
  ),
  Inbox: () => (
    <Svg>
      <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
      <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
    </Svg>
  ),
  Menu: () => (
    <Svg>
      <line x1="4" y1="6" x2="20" y2="6" />
      <line x1="4" y1="12" x2="20" y2="12" />
      <line x1="4" y1="18" x2="20" y2="18" />
    </Svg>
  ),
  PanelLeft: () => (
    <Svg>
      <rect width="18" height="18" x="3" y="3" rx="2" />
      <path d="M9 3v18" />
    </Svg>
  ),
  Shield: () => (
    <Svg>
      <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
    </Svg>
  ),
  Building: () => (
    <Svg>
      <rect width="16" height="20" x="4" y="2" rx="2" />
      <path d="M9 22v-4h6v4" />
      <path d="M8 6h.01" />
      <path d="M16 6h.01" />
      <path d="M12 6h.01" />
      <path d="M12 10h.01" />
      <path d="M12 14h.01" />
      <path d="M16 10h.01" />
      <path d="M16 14h.01" />
      <path d="M8 10h.01" />
      <path d="M8 14h.01" />
    </Svg>
  ),
  IdCard: () => (
    <Svg>
      <rect width="20" height="14" x="2" y="5" rx="2" />
      <circle cx="8" cy="12" r="2" />
      <path d="M14 10h4" />
      <path d="M14 14h4" />
    </Svg>
  ),
  CalendarCheck: () => (
    <Svg>
      <rect width="18" height="18" x="3" y="4" rx="2" />
      <line x1="16" y1="2" x2="16" y2="6" />
      <line x1="8" y1="2" x2="8" y2="6" />
      <line x1="3" y1="10" x2="21" y2="10" />
      <path d="m9 16 2 2 4-4" />
    </Svg>
  ),
  Wallet: () => (
    <Svg>
      <rect width="20" height="14" x="2" y="5" rx="2" />
      <line x1="2" y1="10" x2="22" y2="10" />
    </Svg>
  ),
  Gear: () => (
    <Svg>
      <path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
      <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
    </Svg>
  ),
  Refresh: () => (
    <Svg>
      <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
      <path d="M3 3v5h5" />
      <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
      <path d="M16 16h5v5" />
    </Svg>
  ),
  Reopen: () => (
    <Svg>
      <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
      <path d="M3 3v5h5" />
    </Svg>
  ),
  Play: () => (
    <Svg>
      <polygon points="6 3 20 12 6 21 6 3" />
    </Svg>
  ),
  Ban: () => (
    <Svg>
      <circle cx="12" cy="12" r="10" />
      <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
    </Svg>
  ),
  Eye: () => (
    <Svg>
      <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
      <circle cx="12" cy="12" r="3" />
    </Svg>
  ),
  EyeOff: () => (
    <Svg>
      <path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
      <path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
      <path d="M6.61 6.61A13.53 13.53 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
      <line x1="2" y1="2" x2="22" y2="22" />
    </Svg>
  ),
  Volume: () => (
    <Svg>
      <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
      <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
      <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
    </Svg>
  ),
  VolumeX: () => (
    <Svg>
      <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
      <line x1="22" y1="9" x2="16" y2="15" />
      <line x1="16" y1="9" x2="22" y2="15" />
    </Svg>
  ),
  Monitor: () => (
    <Svg>
      <rect width="20" height="14" x="2" y="3" rx="2" />
      <line x1="8" y1="21" x2="16" y2="21" />
      <line x1="12" y1="17" x2="12" y2="21" />
    </Svg>
  ),
  WifiOff: () => (
    <Svg>
      <line x1="2" y1="2" x2="22" y2="22" />
      <path d="M8.5 16.5a5 5 0 0 1 7 0" />
      <path d="M2 8.82a15 15 0 0 1 4.17-2.65" />
      <path d="M10.66 5c4.01-.36 8.14.9 11.34 3.76" />
      <path d="M16.85 11.25a10 10 0 0 1 2.22 1.68" />
      <path d="M5 13a10 10 0 0 1 5.24-2.76" />
      <line x1="12" y1="20" x2="12.01" y2="20" />
    </Svg>
  ),
  Command: () => (
    <Svg>
      <path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
    </Svg>
  ),
  Hash: () => (
    <Svg>
      <line x1="4" y1="9" x2="20" y2="9" />
      <line x1="4" y1="15" x2="20" y2="15" />
      <line x1="10" y1="3" x2="8" y2="21" />
      <line x1="16" y1="3" x2="14" y2="21" />
    </Svg>
  ),
  Sparkle: () => (
    <Svg>
      <path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" />
    </Svg>
  ),
  Circle: () => (
    <Svg>
      <circle cx="12" cy="12" r="9" />
    </Svg>
  ),
};

/* ═══════════════════════ GENERIC HELPERS ═══════════════════════ */
const cx = (...parts) => parts.filter(Boolean).join(" ");
const nowISO = () => new Date().toISOString();
const pad2 = (n) => String(n).padStart(2, "0");
const plural = (n, one, many) => `${n} ${n === 1 ? one : many || one + "s"}`;
const capitalize = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : "");
const sameSet = (a = [], b = []) =>
  a.length === b.length && a.every((x) => b.includes(x));
// Firestore rejects `undefined` anywhere in a document — round-tripping
// plain data through JSON drops those keys. Only for plain payloads (never
// for objects that contain FieldValue sentinels like arrayUnion).
const plain = (obj) => JSON.parse(JSON.stringify(obj));
const uid4 = () => Math.random().toString(36).slice(2, 8);
// Copy of `obj` without `keys` (stands in for rest destructuring, see top).
const omit = (obj, keys) => {
  const out = {};
  Object.keys(obj || {}).forEach((k) => {
    if (!keys.includes(k)) out[k] = obj[k];
  });
  return out;
};

/* ═══════════════════ DATES — local-calendar safe ═══════════════════
   Due dates are stored as plain "YYYY-MM-DD" strings and must always be
   read in the *local* calendar. Never use toISOString() for a calendar
   day: it converts to UTC and returns yesterday's date for anyone east of
   Greenwich between midnight and the UTC offset (e.g. 00:00–03:00 here). */
const MONTH_NAMES = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
];
const DOW_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];

const toISODate = (date) =>
  `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
const parseISODate = (s) => {
  if (!s || typeof s !== "string") return null;
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.slice(0, 10));
  if (!m) return null;
  const d = new Date(+m[1], +m[2] - 1, +m[3]);
  return Number.isNaN(d.getTime()) ? null : d;
};
const todayISO = () => toISODate(new Date());
const addDays = (date, n) => {
  const x = new Date(date.getFullYear(), date.getMonth(), date.getDate());
  x.setDate(x.getDate() + n);
  return x;
};
const isoAddDays = (iso, n) => toISODate(addDays(parseISODate(iso), n));
// Whole calendar days from a to b. Math.round absorbs the 23h/25h days
// around daylight-saving switches.
const daysBetween = (aISO, bISO) =>
  Math.round((parseISODate(bISO) - parseISODate(aISO)) / 86400000);
// An ISO *timestamp* (createdAt etc.) → the local calendar day it fell on.
const localDateOf = (ts) => {
  if (!ts) return null;
  const d = new Date(ts);
  return Number.isNaN(d.getTime()) ? null : toISODate(d);
};
const startOfWeek = (date) =>
  addDays(date, -((date.getDay() - CFG.weekStart + 7) % 7));
const endOfWeek = (date) => addDays(startOfWeek(date), 6);
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1);
const endOfMonth = (date) =>
  new Date(date.getFullYear(), date.getMonth() + 1, 0);
const toDate = (d) => {
  if (!d) return null;
  if (d instanceof Date) return d;
  if (typeof d === "string" && /^\d{4}-\d{2}-\d{2}$/.test(d))
    return parseISODate(d);
  const x = new Date(d);
  return Number.isNaN(x.getTime()) ? null : x;
};

// "Oct 11, 2026"
const fmtDate = (d) => {
  const date = toDate(d);
  if (!date) return "";
  return date.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
  });
};
// "Oct 11" this year, "Oct 11, 2025" otherwise.
const fmtDateShort = (d) => {
  const date = toDate(d);
  if (!date) return "";
  const sameYear = date.getFullYear() === new Date().getFullYear();
  return date.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    ...(sameYear ? {} : { year: "numeric" }),
  });
};
const fmtTime = (d) => {
  const date = toDate(d);
  if (!date) return "";
  return date.toLocaleTimeString("en-US", {
    hour: "numeric",
    minute: "2-digit",
  });
};
// "Sun, Oct 11, 2026 · 3:45 PM" — for audit trails.
const fmtDateTime = (ts) => {
  const d = toDate(ts);
  if (!d) return "";
  const date = d.toLocaleDateString("en-US", {
    weekday: "short",
    month: "short",
    day: "numeric",
    year: "numeric",
  });
  return `${date} · ${fmtTime(d)}`;
};
// "Today", "Tomorrow", "Yesterday", "Fri" (within a week), else "Oct 11".
const fmtDayRelative = (iso) => {
  if (!iso) return "";
  const diff = daysBetween(todayISO(), iso);
  if (diff === 0) return "Today";
  if (diff === 1) return "Tomorrow";
  if (diff === -1) return "Yesterday";
  if (diff > 1 && diff < 7)
    return parseISODate(iso).toLocaleDateString("en-US", { weekday: "long" });
  return fmtDateShort(iso);
};
const timeAgo = (ts) => {
  const d = toDate(ts);
  if (!d) return "";
  const diff = Date.now() - d.getTime();
  if (diff < 45000) return "just now";
  if (diff < 3600000) return `${Math.max(1, Math.floor(diff / 60000))}m ago`;
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
  if (diff < 7 * 86400000) return `${Math.floor(diff / 86400000)}d ago`;
  return fmtDateShort(d);
};
const greeting = () => {
  const h = new Date().getHours();
  if (h < 5) return "Good evening";
  if (h < 12) return "Good morning";
  if (h < 18) return "Good afternoon";
  return "Good evening";
};

/* ─── Due-date state, shared by tickets and tasks. Finished items are
   never "overdue" — a resolved ticket that was due last week is done. ── */
function dueInfo(dueISO, done) {
  if (!dueISO || !parseISODate(dueISO)) return null;
  const diff = daysBetween(todayISO(), dueISO);
  let state = "later";
  if (done) state = "done";
  else if (diff < 0) state = "overdue";
  else if (diff === 0) state = "today";
  else if (diff <= 2) state = "soon";
  let hint;
  if (diff < 0) hint = `${plural(-diff, "day")} overdue`;
  else if (diff === 0) hint = "Due today";
  else hint = `Due in ${plural(diff, "day")}`;
  return {
    state,
    diff,
    label: fmtDayRelative(dueISO),
    title: `${fmtDate(dueISO)}${done ? "" : ` · ${hint}`}`,
  };
}

/* ─── Period filter ("الفترة") — a filter value is kept symbolic
   ({ kind:'preset', preset:'this_week' }) and resolved against *today*
   every time it's evaluated, so a saved "This week" filter stays correct
   tomorrow instead of freezing the dates it was created with. ─────────── */
const PERIOD_PRESETS = {
  today: { label: "Today", range: (t) => [t, t] },
  yesterday: { label: "Yesterday", range: (t) => [addDays(t, -1), addDays(t, -1)] },
  tomorrow: { label: "Tomorrow", range: (t) => [addDays(t, 1), addDays(t, 1)] },
  this_week: { label: "This week", range: (t) => [startOfWeek(t), endOfWeek(t)] },
  last_week: {
    label: "Last week",
    range: (t) => [startOfWeek(addDays(t, -7)), endOfWeek(addDays(t, -7))],
  },
  next_week: {
    label: "Next week",
    range: (t) => [startOfWeek(addDays(t, 7)), endOfWeek(addDays(t, 7))],
  },
  last_7: { label: "Last 7 days", range: (t) => [addDays(t, -6), t] },
  next_7: { label: "Next 7 days", range: (t) => [t, addDays(t, 6)] },
  last_30: { label: "Last 30 days", range: (t) => [addDays(t, -29), t] },
  this_month: { label: "This month", range: (t) => [startOfMonth(t), endOfMonth(t)] },
  last_month: {
    label: "Last month",
    range: (t) => {
      const p = new Date(t.getFullYear(), t.getMonth() - 1, 1);
      return [startOfMonth(p), endOfMonth(p)];
    },
  },
  next_month: {
    label: "Next month",
    range: (t) => {
      const n = new Date(t.getFullYear(), t.getMonth() + 1, 1);
      return [startOfMonth(n), endOfMonth(n)];
    },
  },
  this_year: {
    label: "This year",
    range: (t) => [new Date(t.getFullYear(), 0, 1), new Date(t.getFullYear(), 11, 31)],
  },
};
const PERIOD_FIELDS = { due: "Due date", created: "Created" };
const DUE_PRESET_IDS = ["today", "tomorrow", "this_week", "next_7", "next_week", "this_month"];
const CREATED_PRESET_IDS = ["today", "yesterday", "this_week", "last_7", "this_month", "last_month", "last_30", "this_year"];
const EMPTY_PERIOD = { field: "due", kind: "all", preset: null, from: null, to: null };

// → { from, to } as ISO dates (inclusive), or null when not a range.
function periodRange(p) {
  if (!p) return null;
  if (p.kind === "preset" && PERIOD_PRESETS[p.preset]) {
    const [a, b] = PERIOD_PRESETS[p.preset].range(new Date());
    return { from: toISODate(a), to: toISODate(b) };
  }
  if (p.kind === "custom" && p.from) {
    const from = p.from;
    const to = p.to && p.to >= p.from ? p.to : p.from;
    return { from, to };
  }
  return null;
}
function periodLabel(p) {
  if (!p || p.kind === "all") return null;
  if (p.kind === "overdue") return "Overdue";
  if (p.kind === "none") return "No due date";
  const field = PERIOD_FIELDS[p.field] || "Date";
  if (p.kind === "preset" && PERIOD_PRESETS[p.preset])
    return `${field}: ${PERIOD_PRESETS[p.preset].label}`;
  const r = periodRange(p);
  if (!r) return null;
  return r.from === r.to
    ? `${field}: ${fmtDateShort(r.from)}`
    : `${field}: ${fmtDateShort(r.from)} – ${fmtDateShort(r.to)}`;
}
function matchesPeriod(item, p, isDone) {
  if (!p || p.kind === "all") return true;
  if (p.kind === "overdue") return dueInfo(item.dueDate, isDone)?.state === "overdue";
  if (p.kind === "none") return !item.dueDate;
  const r = periodRange(p);
  if (!r) return true;
  const day = p.field === "created" ? localDateOf(item.createdAt) : item.dueDate;
  if (!day) return false;
  return day >= r.from && day <= r.to;
}

/* ─── Monthly workspace stamp (kept for reporting compatibility). ────── */
const workspaceFields = (date = new Date()) => {
  const y = date.getFullYear(),
    m = date.getMonth() + 1;
  return {
    workspaceYear: y,
    workspaceMonth: m,
    workspaceMonthName: MONTH_NAMES[m - 1],
    workspaceKey: `${y}-${pad2(m)}`,
  };
};

/* ═══════════════════════ PEOPLE & AVATARS ═══════════════════════ */
// Same palette and hash as js/utils.js, so a person keeps one color everywhere.
const AVATAR_COLORS = [
  "#3b82f6",
  "#8b5cf6",
  "#ec4899",
  "#f59e0b",
  "#22c55e",
  "#06b6d4",
  "#f43f5e",
  "#14b8a6",
];
const colorFor = (str) => {
  let hash = 0;
  for (let i = 0; i < (str || "").length; i++)
    hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
  return AVATAR_COLORS[hash % AVATAR_COLORS.length];
};
const initialsOf = (name, email) => {
  const base = (name || email || "?").trim();
  const parts = base.split(/[\s._@-]+/).filter(Boolean);
  if (parts.length >= 2 && name) return (parts[0][0] + parts[1][0]).toUpperCase();
  return base.charAt(0).toUpperCase();
};
// Display name for a users/{uid} profile doc.
const userLabel = (u) =>
  (u?.name && u.name.trim()) || (u?.email || u?._id || "?").split("@")[0];
const authorOf = (who) => ({
  uid: who?.uid || null,
  name: who?.name || who?.email?.split("@")[0] || "User",
  email: who?.email || "",
});
const ROLE_LABELS = {
  "super-admin": "Super Admin",
  admin: "Admin",
  supervisor: "Supervisor",
  employee: "Employee",
};
const roleLabel = (r) => ROLE_LABELS[r] || capitalize(r || "employee");
const isEditorRole = (r) => r === "supervisor" || r === "admin" || r === "super-admin";
const isAdminRole = (r) => r === "admin" || r === "super-admin";

/* ═══════════════════ STATUS / PRIORITY MODEL ═══════════════════ */
const TICKET_STATUSES = ["open", "assigned", "in-progress", "resolved", "closed", "rejected", "cancelled"];
const STATUS_LABELS = {
  open: "Open",
  assigned: "Assigned",
  "in-progress": "In Progress",
  resolved: "Resolved",
  closed: "Closed",
  rejected: "Rejected",
  cancelled: "Cancelled",
};
const statusLabel = (s) => STATUS_LABELS[s] ?? capitalize(s);
const STATUS_TONE = {
  open: "blue",
  assigned: "indigo",
  "in-progress": "amber",
  resolved: "green",
  closed: "teal",
  rejected: "red",
  cancelled: "gray",
};
const ACTIVE_TICKET = ["open", "assigned", "in-progress"];
const DONE_TICKET = ["resolved", "closed"];
const ticketIsActive = (t) => ACTIVE_TICKET.includes(t.status);
const ticketIsDone = (t) => DONE_TICKET.includes(t.status);
const ticketIsFinished = (t) => !ticketIsActive(t);

const TASK_STATUSES = ["pending", "accepted", "in-progress", "completed", "rejected", "cancelled"];
const TASK_STATUS_LABELS = {
  pending: "Pending",
  accepted: "Accepted",
  "in-progress": "In Progress",
  completed: "Completed",
  rejected: "Rejected",
  cancelled: "Cancelled",
};
const TASK_TONE = {
  pending: "gray",
  accepted: "indigo",
  "in-progress": "amber",
  completed: "green",
  rejected: "red",
  cancelled: "gray",
};
const taskStatusLabel = (s) => TASK_STATUS_LABELS[s] ?? capitalize(s);
// Old task docs only carry a boolean `done`.
const taskStatusOf = (t) => t.status || (t.done ? "completed" : "pending");

const PRIORITIES = ["critical", "high", "medium", "low"];
const PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
const PRIORITY_LABELS = { critical: "Critical", high: "High", medium: "Medium", low: "Low" };
const priorityLabel = (p) => PRIORITY_LABELS[p] || capitalize(p || "medium");

/* ═══════════════════ ASSIGNMENT ENGINE ═══════════════════
   assignment = { mode, uids, departments }
     individual / multiple → named people (uids)
     department / multi-department / company → shared, "first to finish
     wins" (completion.completedBy is locked by the Firestore rules).
   Legacy tickets only carry a single `assignee` object; assignmentOf()
   synthesizes the equivalent individual assignment for them. ───────── */
const blankAssignment = () => ({ mode: "individual", uids: [], departments: [] });
const SHARED_MODES = ["department", "multi-department", "company"];
const isSharedAssignment = (a) => !!a && SHARED_MODES.includes(a.mode);
const assignmentOf = (item) => {
  if (item?.assignment) {
    const a = item.assignment;
    return {
      mode: a.mode || "individual",
      uids: Array.isArray(a.uids) ? a.uids : [],
      departments: Array.isArray(a.departments) ? a.departments : [],
    };
  }
  if (item?.assignee?.uid)
    return { mode: "individual", uids: [item.assignee.uid], departments: [] };
  return null;
};
const assigneeUidsOf = (item) => {
  const a = assignmentOf(item);
  return a && (a.mode === "individual" || a.mode === "multiple") ? a.uids : [];
};
const hasPeople = (a) =>
  !!a && (a.mode === "individual" || a.mode === "multiple") && a.uids.length > 0;
const hasTarget = (a) => hasPeople(a) || isSharedAssignment(a);
const isAssignedToMe = (item, uid) => assigneeUidsOf(item).includes(uid);
const isUnassigned = (item) => !hasTarget(assignmentOf(item));
const resolveAssignees = (assignment, users) => {
  if (!assignment || !["individual", "multiple"].includes(assignment.mode)) return [];
  return (assignment.uids || [])
    .map((uid) => users.find((u) => u._id === uid))
    .filter(Boolean)
    .map((u) => ({ uid: u._id, name: userLabel(u), email: u.email || "" }));
};
const normalizeAssignment = (a) => {
  const n = {
    mode: a?.mode || "individual",
    uids: [...new Set(a?.uids || [])],
    departments: [...new Set(a?.departments || [])],
  };
  if (n.mode === "individual") {
    n.uids = n.uids.slice(0, 1);
    n.departments = [];
  } else if (n.mode === "multiple") n.departments = [];
  else {
    n.uids = [];
    if (n.mode === "company") n.departments = DEPARTMENTS.map((d) => d.id);
    if (n.mode === "department" && !n.departments.length) n.departments = [MY_DEPARTMENT];
    if (n.mode === "department") n.departments = n.departments.slice(0, 1);
  }
  return n;
};
// The `departments` array every doc is gated on in firestore.rules.
const itemDepartments = (assignment, myDept = MY_DEPARTMENT) => {
  if (!assignment) return [myDept];
  switch (assignment.mode) {
    case "department":
    case "multi-department":
      return assignment.departments.length ? assignment.departments : [myDept];
    case "company":
      return DEPARTMENTS.map((d) => d.id);
    default:
      return [myDept];
  }
};
const assignmentSummaryText = (item, users) => {
  const a = assignmentOf(item);
  if (!a) return "Unassigned";
  if (a.mode === "individual" || a.mode === "multiple") {
    const people = resolveAssignees(a, users);
    if (people.length) return people.map((p) => p.name).join(", ");
    if (item.assignee?.name) return item.assignee.name;
    return "Unassigned";
  }
  if (a.mode === "company") return "Entire company";
  return a.departments.map(deptLabelOf).join(", ");
};
// Who started the item. Tickets record createdBy.{uid,name,email}; old
// docs have no uid, so fall back to matching the email against users.
const creatorUidOf = (item, users = []) =>
  item?.createdBy?.uid ||
  item?.ownerUid ||
  users.find((u) => u.email && item?.createdBy?.email && u.email === item.createdBy.email)?._id ||
  null;
// Everyone with a stake in an item: creator, named assignees, commenters.
function participantsOf(item, users = []) {
  const set = new Set();
  const c = creatorUidOf(item, users);
  if (c) set.add(c);
  assigneeUidsOf(item).forEach((u) => set.add(u));
  (item.comments || []).forEach((cm) => {
    if (cm.kind === "event") return;
    const uid = cm.author?.uid || users.find((u) => u.email && u.email === cm.author?.email)?._id;
    if (uid) set.add(uid);
  });
  return set;
}

/* ─── Activity events. Status changes, (re)assignments etc. are appended
   to the item's `comments` array with kind:'event' — the one field every
   participant is already allowed to write under the existing rules — so
   each ticket/task carries its own complete history without a new
   collection. Real comments have no `kind`. ─────────────────────────── */
const makeEvent = (actor, type, text, extra = {}) =>
  plain({ kind: "event", type, text, author: authorOf(actor), at: nowISO(), ...extra });
const realComments = (item) => (item.comments || []).filter((c) => c.kind !== "event");
const lastActivityAt = (item) => {
  let latest = item.updatedAt || item.createdAt || "";
  (item.comments || []).forEach((c) => {
    if (c.at && c.at > latest) latest = c.at;
  });
  return latest;
};

/* ─── Permissions — mirror firestore.rules so the UI only ever offers
   actions the server will accept. ─────────────────────────────────── */
function ticketPerms(t, ctx) {
  const a = assignmentOf(t);
  const me = ctx.me.uid;
  const individual = a?.mode === "individual" && a.uids.includes(me);
  const inMultiple = a?.mode === "multiple" && a.uids.includes(me);
  const shared = isSharedAssignment(a);
  return {
    edit: ctx.canEdit,
    assign: ctx.canEdit,
    del: ctx.canDelete,
    status: ctx.canEdit || individual || shared,
    toggleMine: inMultiple,
    isAssignee: individual || inMultiple,
    shared,
    sharedDone: shared && !!t.completion?.completedBy,
    comment: true,
  };
}
// Statuses this user may move a ticket to right now.
function allowedTicketStatuses(t, perms, ctx) {
  if (!perms.status) return [];
  // Once a shared ticket's completion is locked, only an editor can reset
  // it (re-opening clears the lock), so other members can't move it back.
  if (perms.sharedDone && !ctx.canEdit)
    return TICKET_STATUSES.filter((s) => DONE_TICKET.includes(s));
  return TICKET_STATUSES;
}

function taskPerms(t, ctx) {
  const a = t.assignment ? assignmentOf(t) : null;
  const me = ctx.me.uid;
  const mine = t.ownerUid === me;
  const individual = a?.mode === "individual" && a.uids.includes(me) && !mine;
  const inMultiple = a?.mode === "multiple" && a.uids.includes(me);
  const shared = isSharedAssignment(a);
  const manage = mine || (ctx.canEdit && t.visibility === "shared");
  return {
    manage,
    status: manage || individual || shared,
    toggleMine: inMultiple,
    isAssignee: individual || inMultiple,
    individualAssignee: individual,
    del: mine || (ctx.canDelete && t.visibility === "shared"),
    shared,
    sharedDone: shared && !!t.completion?.completedBy,
    mine,
  };
}
// "Done" from the viewer's perspective: shared → anyone finished it;
// multiple → *my* part is finished; otherwise the task's own status.
function taskDoneFor(t, uid) {
  const a = t.assignment ? assignmentOf(t) : null;
  if (isSharedAssignment(a)) return !!t.completion?.completedBy || taskStatusOf(t) === "completed";
  if (a?.mode === "multiple" && a.uids.includes(uid))
    return t.assigneeProgress?.[uid]?.status === "completed";
  return taskStatusOf(t) === "completed";
}
const taskFinished = (t, uid) =>
  taskDoneFor(t, uid) || ["rejected", "cancelled"].includes(taskStatusOf(t));
// Tasks that are "mine to do": assigned to me, or my own personal ones.
const taskIsMineToDo = (t, uid) =>
  isAssignedToMe(t, uid) || (t.ownerUid === uid && !hasPeople(assignmentOf(t)));

/* ═══════════════════════ FIRESTORE HOOKS ═══════════════════════ */
const readCache = (key) => {
  try {
    const raw = key && localStorage.getItem(key);
    return raw ? JSON.parse(raw) : null;
  } catch {
    return null;
  }
};
const writeCache = (key, rows) => {
  try {
    localStorage.setItem(key, JSON.stringify(rows));
  } catch {}
};
const clearDataCaches = () => {
  try {
    Object.keys(localStorage)
      .filter((k) => k.startsWith("msindaha_cache_"))
      .forEach((k) => localStorage.removeItem(k));
  } catch {}
};
const fsApi = (name) => ({
  add: (d) => window.__fb.addDoc(window.__fb.collection(window.__fb.db, name), d),
  update: (id, d) => window.__fb.updateDoc(window.__fb.doc(window.__fb.db, name, id), d),
  remove: (id) => window.__fb.deleteDoc(window.__fb.doc(window.__fb.db, name, id)),
  set: (id, d) => window.__fb.setDoc(window.__fb.doc(window.__fb.db, name, id), d),
});

/* useCollection — live collection, optionally scoped to `departments
   array-contains dept` (REQUIRED for department-gated collections: rules
   are not filters, an unscoped query is rejected outright). Seeds from a
   localStorage cache for an instant first paint. `orderField: null`
   skips the orderBy — needed for `users`, where docs without createdAt
   would otherwise be silently excluded from the result. */
function useCollection(name, { dept = null, orderField = "createdAt" } = {}) {
  const cacheKey = `msindaha_cache_${name}${dept ? `_${dept}` : ""}`;
  const [state, setState] = React.useState(() => {
    const cached = readCache(cacheKey);
    return { rows: cached || [], loaded: !!cached, error: null };
  });
  React.useEffect(() => {
    const { db, collection, onSnapshot, query, orderBy, where } = window.__fb;
    const clauses = [];
    if (dept) clauses.push(where("departments", "array-contains", dept));
    if (orderField) clauses.push(orderBy(orderField, "asc"));
    return onSnapshot(
      query(collection(db, name), ...clauses),
      (snap) => {
        const rows = snap.docs.map((d) => ({ _id: d.id, ...d.data() }));
        setState({ rows, loaded: true, error: null });
        writeCache(cacheKey, rows);
      },
      (err) => {
        console.error(`useCollection(${name}) snapshot error:`, err);
        setState((s) => ({ ...s, loaded: true, error: err }));
      },
    );
  }, [name, dept, orderField]);
  const api = React.useMemo(() => fsApi(name), [name]);
  return { ...state, ...api };
}

/* useScopedCollection — tasks/notes have private + shared items, so two
   rule-compliant listeners (shared, and mine) are merged client-side. */
function useScopedCollection(name, uid, dept) {
  const cacheKey = uid ? `msindaha_cache_${name}_${uid}${dept ? `_${dept}` : ""}` : null;
  const cached = React.useMemo(() => readCache(cacheKey) || [], [cacheKey]);
  const [shared, setShared] = React.useState(() => cached.filter((d) => d.visibility === "shared"));
  const [mine, setMine] = React.useState(() => cached.filter((d) => d.ownerUid === uid));
  const [loadedParts, setLoadedParts] = React.useState(() => (cached.length ? 2 : 0));
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    if (!uid) return;
    const { db, collection, query, where, onSnapshot } = window.__fb;
    const toRows = (snap) => snap.docs.map((d) => ({ _id: d.id, ...d.data() }));
    const sharedQ = [where("visibility", "==", "shared")];
    const mineQ = [where("ownerUid", "==", uid)];
    if (dept) {
      sharedQ.push(where("departments", "array-contains", dept));
      mineQ.push(where("departments", "array-contains", dept));
    }
    const seen = new Set();
    const markLoaded = (k) => {
      if (!seen.has(k)) {
        seen.add(k);
        setLoadedParts(seen.size);
      }
    };
    const onErr = (label) => (err) => {
      console.error(`useScopedCollection(${name}/${label}) snapshot error:`, err);
      setError(err);
      markLoaded(label);
    };
    const u1 = onSnapshot(
      query(collection(db, name), ...sharedQ),
      (s) => {
        setShared(toRows(s));
        markLoaded("shared");
      },
      onErr("shared"),
    );
    const u2 = onSnapshot(
      query(collection(db, name), ...mineQ),
      (s) => {
        setMine(toRows(s));
        markLoaded("mine");
      },
      onErr("mine"),
    );
    return () => {
      u1();
      u2();
    };
  }, [name, uid, dept]);

  const rows = React.useMemo(() => {
    const map = new Map();
    shared.forEach((d) => map.set(d._id, d));
    mine.forEach((d) => map.set(d._id, d));
    return [...map.values()].sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
  }, [shared, mine]);

  React.useEffect(() => {
    if (cacheKey && loadedParts >= 2) writeCache(cacheKey, rows);
  }, [cacheKey, rows, loadedParts]);

  const api = React.useMemo(() => fsApi(name), [name]);
  return { rows, loaded: loadedParts >= 2, error, ...api };
}

/* useProfile — live users/{uid}. undefined = loading, null = no access. */
function useProfile(user) {
  const [profile, setProfile] = React.useState(undefined);
  React.useEffect(() => {
    if (!user) {
      setProfile(undefined);
      return;
    }
    const { db, doc, onSnapshot } = window.__fb;
    return onSnapshot(
      doc(db, "users", user.uid),
      (snap) => {
        if (!snap.exists()) return setProfile(null);
        const d = snap.data();
        if (d.disabled === true) return setProfile(null);
        setProfile({
          role: d.role || "employee",
          department: d.department || "payroll",
          name: (d.name || "").trim(),
        });
      },
      (err) => {
        console.error("useProfile snapshot error:", err);
        setProfile(null);
      },
    );
  }, [user?.uid]);
  return profile;
}

/* useDepartments — merges Super-Admin-added departments into DEPARTMENTS
   and bumps a version so consumers re-render with the new list. */
function useDepartments(enabled) {
  const [version, setVersion] = React.useState(0);
  React.useEffect(() => {
    if (!enabled) return;
    const { db, collection, onSnapshot } = window.__fb;
    return onSnapshot(
      collection(db, "departments"),
      (snap) => {
        snap.docs.forEach((d) => {
          const label = d.data().label || d.id;
          const existing = DEPARTMENTS.find((x) => x.id === d.id);
          if (existing) existing.label = label;
          else DEPARTMENTS.push({ id: d.id, label });
        });
        setVersion((v) => v + 1);
      },
      (err) => console.error("departments snapshot error:", err),
    );
  }, [enabled]);
  return version;
}

function useLocalStorage(key, initial) {
  const [val, setVal] = React.useState(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw !== null ? JSON.parse(raw) : initial;
    } catch {
      return initial;
    }
  });
  React.useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(val));
    } catch {}
  }, [key, val]);
  return [val, setVal];
}

// Re-render periodically so relative times ("3m ago", "Today") stay fresh
// — including across midnight, when "Tomorrow" must become "Today".
function useNow(intervalMs = 60000) {
  const [, tick] = React.useState(0);
  React.useEffect(() => {
    const id = setInterval(() => tick((n) => n + 1), intervalMs);
    return () => clearInterval(id);
  }, [intervalMs]);
}

function useOnline() {
  const [online, setOnline] = React.useState(() => navigator.onLine !== false);
  React.useEffect(() => {
    const on = () => setOnline(true);
    const off = () => setOnline(false);
    window.addEventListener("online", on);
    window.addEventListener("offline", off);
    return () => {
      window.removeEventListener("online", on);
      window.removeEventListener("offline", off);
    };
  }, []);
  return online;
}

/* ─── Sequential display IDs (TKT-0042). A transaction on
   counters/{prefix}_{dept} makes concurrent creates race-free; if that
   collection isn't writable yet (rules not deployed) or we're offline, it
   falls back to max(existing)+1 — never array length, which re-issued
   existing IDs as soon as anything was deleted. ──────────────────────── */
const numberOfDisplayId = (id) => {
  const m = /(\d+)\s*$/.exec(String(id || ""));
  return m ? parseInt(m[1], 10) : 0;
};
async function nextDisplayId(prefix, items) {
  const localMax = items.reduce((m, it) => Math.max(m, numberOfDisplayId(it.displayId)), 0);
  const fmt = (n) => `${prefix}-${String(n).padStart(4, "0")}`;
  try {
    const { db, doc, runTransaction } = window.__fb;
    if (!runTransaction) throw new Error("no transactions");
    const ref = doc(db, "counters", `${prefix.toLowerCase()}_${MY_DEPARTMENT}`);
    const n = await Promise.race([
      runTransaction(db, async (tx) => {
        const snap = await tx.get(ref);
        const cur = snap.exists() ? Number(snap.data().value) || 0 : 0;
        const next = Math.max(cur, localMax) + 1;
        tx.set(ref, { value: next, updatedAt: nowISO() });
        return next;
      }),
      new Promise((_, rej) => setTimeout(() => rej(new Error("counter timeout")), 6000)),
    ]);
    return fmt(n);
  } catch (e) {
    console.warn("Display-ID counter unavailable, using local sequence:", e?.code || e?.message);
    return fmt(localMax + 1);
  }
}

/* ═══════════════════════ NOTIFICATION SENDERS ═══════════════════════
   Persisted notifications, one doc per recipient. Written by whoever
   performs the action; only the recipient can read them (rules). Failures
   are logged, never surfaced: the action itself already succeeded. */
function notifyUsers(uids, payload, actor) {
  const list = [...new Set(uids || [])].filter((u) => u && u !== actor?.uid);
  if (!list.length) return Promise.resolve();
  const { db, collection, addDoc } = window.__fb;
  const base = plain({
    type: payload.type || "info",
    title: payload.title || "Notification",
    msg: payload.msg || "",
    collName: payload.collName || null,
    itemId: payload.itemId || null,
    displayId: payload.displayId || null,
    dept: payload.dept || MY_DEPARTMENT,
    actorUid: actor?.uid || null,
    actorName: actor?.name || null,
    actorEmail: actor?.email || null,
  });
  return Promise.allSettled(
    list.map((recipientUid) =>
      addDoc(collection(db, "notifications"), {
        ...base,
        recipientUid,
        read: false,
        createdAt: nowISO(),
      }),
    ),
  ).then((res) => {
    const failed = res.filter((r) => r.status === "rejected");
    if (failed.length) console.error("notifyUsers: some notifications failed", failed);
  });
}
const activeUsersOf = (users) => users.filter((u) => !u.disabled);
const deptMemberUids = (users, deptIds) =>
  activeUsersOf(users)
    .filter((u) => deptIds.includes(u.department || "payroll") && u.role !== "super-admin")
    .map((u) => u._id);
const deptEditorUids = (users, dept = MY_DEPARTMENT) =>
  activeUsersOf(users)
    .filter((u) => (u.department || "payroll") === dept && ["supervisor", "admin"].includes(u.role))
    .map((u) => u._id);

/* ─── Notification sound — a soft synthesized two-tone ping. Browsers
   block audio until the first user gesture, so it waits for one. ───── */
let __audioCtx = null;
let __hasInteracted = false;
["pointerdown", "keydown"].forEach((ev) =>
  window.addEventListener(
    ev,
    () => {
      __hasInteracted = true;
    },
    { once: true, capture: true },
  ),
);
function playNotifySound() {
  if (!__hasInteracted) return;
  try {
    __audioCtx = __audioCtx || new (window.AudioContext || window.webkitAudioContext)();
    const ctx = __audioCtx;
    if (ctx.state === "suspended") ctx.resume();
    const t0 = ctx.currentTime;
    [
      [880, 0],
      [1320, 0.11],
    ].forEach(([freq, at]) => {
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = "sine";
      osc.frequency.setValueAtTime(freq, t0 + at);
      gain.gain.setValueAtTime(0.0001, t0 + at);
      gain.gain.exponentialRampToValueAtTime(0.14, t0 + at + 0.02);
      gain.gain.exponentialRampToValueAtTime(0.0001, t0 + at + 0.3);
      osc.connect(gain).connect(ctx.destination);
      osc.start(t0 + at);
      osc.stop(t0 + at + 0.32);
    });
  } catch {}
}

/* ─── CSV export. The UTF-8 BOM makes Excel read Arabic names correctly. */
function downloadCSV(filename, rows, columns) {
  const esc = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
  const header = columns.map((c) => esc(c.label)).join(",");
  const body = rows.map((r) => columns.map((c) => esc(c.get(r))).join(",")).join("\r\n");
  const blob = new Blob(["﻿" + header + "\r\n" + body], {
    type: "text/csv;charset=utf-8;",
  });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

const copyText = async (text) => {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    try {
      const ta = document.createElement("textarea");
      ta.value = text;
      ta.style.position = "fixed";
      ta.style.opacity = "0";
      document.body.appendChild(ta);
      ta.select();
      document.execCommand("copy");
      ta.remove();
      return true;
    } catch {
      return false;
    }
  }
};

// Human-readable reason for a failed Firestore write.
const writeErrorMsg = (e) => {
  const code = e?.code || "";
  if (code.includes("permission-denied"))
    return "You don't have permission to do that. If you should, ask an admin to deploy the latest Firestore rules.";
  if (code.includes("unavailable") || code.includes("network"))
    return "You appear to be offline. Please try again when you're back online.";
  if (code.includes("not-found")) return "This item no longer exists.";
  return e?.message?.replace(/^Firebase:\s*/, "") || "Something went wrong. Please try again.";
};

/* ═══════════════════════ APP CONTEXT ═══════════════════════ */
const AppCtx = React.createContext(null);
const useApp = () => React.useContext(AppCtx);
