import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";

interface User {
  id: string;
  email: string;
  display_name: string;
  office: string;
  additional_offices?: string[];
  job_title?: string | null;
}

interface MembershipEntry {
  user_id: string;
  project_ids: string[];
  lead_project_ids: string[];
  roles: string[];
}

interface ProjectSummary {
  id: string;
  title: string;
  type: string;
  reference?: string | null;
}

interface MeUser {
  id: string;
  global_role: string;
}

interface DepartmentMember {
  user_id: string;
  email: string;
  display_name: string;
  office: string;
  job_title?: string | null;
}

interface Department {
  id: string;
  name: string;
  office: string;
  lead_user_id?: string;
  lead_display_name?: string;
  lead_email?: string;
  members: DepartmentMember[];
}

const OFFICE_ORDER = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"];

// ROLE_ORDER drives pill ordering: seniority hierarchy first, then alphabetical
// for anything new the firm starts using. Compared case-insensitively against
// the trimmed job_title — see roleKey().
const ROLE_ORDER = [
  "partner",
  "counsel",
  "counsel knowledge lawyer",
  "senior associate",
  "associate",
  "junior associate",
  "trainee",
  "pa",
  "paralegal",
  "secretary",
];

let users: User[] = [];
let departments: Department[] = [];
let memberships: MembershipEntry[] = [];
let projectsList: ProjectSummary[] = [];
let me: MeUser | null = null;
let groupBy: "office" | "department" = "office";
let activeOffice = "all";
let activeRole = "all";
let activeProjectIDs: Set<string> = new Set();
let searchQuery = "";

// t-paliad-223 (#53) — explicit click-to-select layer ON TOP of the existing
// filter pills. When selection.size > 0 the sticky footer takes over the
// broadcast action and targets only the explicit subset; with empty
// selection the existing top-bar broadcast button still targets the whole
// filter result (purely additive).
//
// Invariant: selection only ever holds user_ids that match the current
// filter set — render() prunes drop-outs every cycle. This keeps the
// counter honest and avoids "hidden-but-selected" debug nightmares.
const selectedUserIDs: Set<string> = new Set();
// For Shift-click range select — the user_id of the most recent toggle
// in the currently-rendered list order. Reset to null on any filter
// change so the range never spans an invisible row.
let lastToggledUserID: string | null = null;
// Snapshot of the rendered user-IDs in DOM order, refreshed on each render.
// Drives Shift-click range expansion and the master-checkbox "select all
// visible" action.
let renderedUserIDs: string[] = [];

const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
const ICON_PIN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';

function esc(s: string): string {
  const d = document.createElement("div");
  d.textContent = s;
  return d.innerHTML;
}

function officeLabel(key: string): string {
  return tDyn("office." + key) || key;
}

// roleKey normalises a free-text job_title for case-insensitive comparison
// (DB has both "Associate" and "associate" today — t-paliad-085).
function roleKey(jobTitle: string | null | undefined): string {
  return (jobTitle ?? "").trim().toLowerCase();
}

// roleLabel looks up a translation under team.role.<slug>; falls back to the
// raw job_title so newly-introduced titles render even without an i18n entry.
function roleLabel(jobTitle: string): string {
  const slug = roleKey(jobTitle).replace(/\s+/g, "_");
  return tDyn("team.role." + slug) || jobTitle;
}

function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "?";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

async function loadAll() {
  const [usersResp, deptsResp, membershipsResp, projectsResp, meResp] = await Promise.all([
    fetch("/api/users"),
    fetch("/api/partner-units?include=members"),
    fetch("/api/team/memberships"),
    fetch("/api/projects"),
    fetch("/api/me"),
  ]);
  if (usersResp.ok) users = (await usersResp.json()) as User[];
  if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
  if (membershipsResp.ok) memberships = (await membershipsResp.json()) as MembershipEntry[];
  if (projectsResp.ok) {
    const raw = (await projectsResp.json()) as ProjectSummary[];
    projectsList = raw;
  }
  if (meResp.ok) me = (await meResp.json()) as MeUser;
  buildOfficeFilters();
  buildRoleFilters();
  buildProjectFilter();
  render();
  updateBroadcastButton();
}

function presentOffices(): string[] {
  const seen = new Set<string>();
  for (const u of users) seen.add(u.office);
  return OFFICE_ORDER.filter((k) => seen.has(k)).concat(
    Array.from(seen).filter((k) => !OFFICE_ORDER.includes(k)).sort(),
  );
}

function buildOfficeFilters() {
  const container = document.getElementById("team-office-filters")!;
  const offices = presentOffices();
  const allBtn = `<button class="filter-pill${activeOffice === "all" ? " active" : ""}" data-office="all" type="button">${esc(t("team.filter.all"))}</button>`;
  const pills = offices
    .map((k) => `<button class="filter-pill${activeOffice === k ? " active" : ""}" data-office="${esc(k)}" type="button">${esc(officeLabel(k))}</button>`)
    .join("");
  container.innerHTML = allBtn + pills;
  container.querySelectorAll<HTMLButtonElement>(".filter-pill").forEach((btn) => {
    btn.addEventListener("click", () => {
      activeOffice = btn.dataset.office ?? "all";
      container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
      btn.classList.add("active");
      render();
    });
  });
}

// presentRoles returns the distinct job_titles in the loaded users, ordered by
// ROLE_ORDER (seniority) with anything unrecognised tacked on alphabetically.
// Values are case-folded for grouping; the first-seen casing wins for display.
function presentRoles(): string[] {
  const seen = new Map<string, string>();
  for (const u of users) {
    const jt = (u.job_title ?? "").trim();
    if (!jt) continue;
    const key = jt.toLowerCase();
    if (!seen.has(key)) seen.set(key, jt);
  }
  return [...seen.values()].sort((a, b) => {
    const ai = ROLE_ORDER.indexOf(a.toLowerCase());
    const bi = ROLE_ORDER.indexOf(b.toLowerCase());
    if (ai !== -1 && bi !== -1) return ai - bi;
    if (ai !== -1) return -1;
    if (bi !== -1) return 1;
    return a.localeCompare(b);
  });
}

function buildRoleFilters() {
  const container = document.getElementById("team-role-filters")!;
  const roles = presentRoles();
  const activeKey = activeRole.toLowerCase();
  const allBtn = `<button class="filter-pill${activeRole === "all" ? " active" : ""}" data-role="all" type="button">${esc(t("team.filter.all"))}</button>`;
  const pills = roles
    .map((r) => `<button class="filter-pill${activeKey === r.toLowerCase() ? " active" : ""}" data-role="${esc(r)}" type="button">${esc(roleLabel(r))}</button>`)
    .join("");
  container.innerHTML = allBtn + pills;
  container.querySelectorAll<HTMLButtonElement>(".filter-pill").forEach((btn) => {
    btn.addEventListener("click", () => {
      activeRole = btn.dataset.role ?? "all";
      container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
      btn.classList.add("active");
      render();
    });
  });
}

function userMatchesSearch(u: User): boolean {
  if (!searchQuery) return true;
  const q = searchQuery.toLowerCase();
  const hay = [
    u.display_name,
    u.email,
    u.job_title ?? "",
    u.office,
    officeLabel(u.office),
    ...(u.additional_offices ?? []).map(officeLabel),
  ]
    .join(" ")
    .toLowerCase();
  return hay.includes(q);
}

function userMatchesOffice(u: User): boolean {
  if (activeOffice === "all") return true;
  if (u.office === activeOffice) return true;
  return (u.additional_offices ?? []).includes(activeOffice);
}

function userMatchesRole(u: User): boolean {
  if (activeRole === "all") return true;
  return roleKey(u.job_title) === activeRole.toLowerCase();
}

// userMatchesProject returns true when the project filter is empty or
// when the user is a direct member of at least one selected project.
// Inherited memberships intentionally don't qualify here — users want
// "people I can mail on this matter", which means direct membership.
function userMatchesProject(u: User): boolean {
  if (activeProjectIDs.size === 0) return true;
  const m = memberships.find((m) => m.user_id === u.id);
  if (!m) return false;
  for (const pid of m.project_ids) {
    if (activeProjectIDs.has(pid)) return true;
  }
  return false;
}

// canBroadcast reports whether the current user is allowed to send a
// broadcast given the active project filter. global_admin always wins.
// Otherwise the user must be a 'lead' on every project they have
// selected (or, when no project is selected, on at least one of their
// own projects).
function canBroadcast(): boolean {
  if (!me) return false;
  if (me.global_role === "global_admin") return true;
  const myMembership = memberships.find((m) => m.user_id === me?.id);
  if (!myMembership || !myMembership.lead_project_ids.length) return false;
  if (activeProjectIDs.size === 0) {
    // No project filter — allow when caller leads at least one project.
    // Server-side check still runs per-broadcast so a non-lead can never
    // actually send.
    return true;
  }
  for (const pid of activeProjectIDs) {
    if (!myMembership.lead_project_ids.includes(pid)) return false;
  }
  return true;
}

function buildProjectFilter() {
  const container = document.getElementById("team-project-filter");
  if (!container) return;
  // Show only projects the caller can see — projectsList already does
  // that via the visibility-gated /api/projects endpoint.
  const sortedProjects = [...projectsList].sort((a, b) =>
    (a.title || "").localeCompare(b.title || ""),
  );
  const options = sortedProjects
    .map(
      (p) =>
        `<label class="filter-checkbox"><input type="checkbox" data-project-id="${esc(p.id)}" ${
          activeProjectIDs.has(p.id) ? "checked" : ""
        } /> <span>${esc(p.title)}</span></label>`,
    )
    .join("");
  const summary = activeProjectIDs.size === 0
    ? (t("team.filter.project.all") || "Alle Projekte")
    : `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`;
  container.innerHTML = `
    <button type="button" class="filter-pill team-project-trigger" data-project-trigger>
      <span class="team-project-summary">${esc(t("team.filter.project") || "Projekt")}: ${esc(summary)}</span>
    </button>
    <div class="team-project-panel hidden" data-project-panel>
      <div class="team-project-actions">
        <button type="button" class="link-button" data-project-clear>${esc(t("team.filter.project.clear") || "Alle abwählen")}</button>
      </div>
      <div class="team-project-options">${options}</div>
    </div>
  `;
  const trigger = container.querySelector<HTMLButtonElement>("[data-project-trigger]");
  const panel = container.querySelector<HTMLDivElement>("[data-project-panel]");
  trigger?.addEventListener("click", (e) => {
    e.stopPropagation();
    panel?.classList.toggle("hidden");
  });
  document.addEventListener("click", (e) => {
    if (!container.contains(e.target as Node)) panel?.classList.add("hidden");
  });
  container.querySelectorAll<HTMLInputElement>("input[data-project-id]").forEach((cb) => {
    cb.addEventListener("change", () => {
      const pid = cb.dataset.projectId!;
      if (cb.checked) activeProjectIDs.add(pid);
      else activeProjectIDs.delete(pid);
      buildProjectFilter();
      render();
      updateBroadcastButton();
    });
  });
  container.querySelector<HTMLButtonElement>("[data-project-clear]")?.addEventListener("click", () => {
    activeProjectIDs.clear();
    buildProjectFilter();
    render();
    updateBroadcastButton();
  });
}

function buildBroadcastButton() {
  const wrap = document.getElementById("team-broadcast-wrap");
  if (!wrap) return;
  // Wait for /api/me so the affordance never flickers between admin (form)
  // and non-admin (mailto) on initial paint. canBroadcast() already returns
  // false when me is null but we'd briefly render the mailto anchor before
  // the admin form, which is visually jarring.
  if (!me) {
    wrap.innerHTML = "";
    wrap.style.display = "none";
    return;
  }
  wrap.style.display = "";
  const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
  const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
  if (canBroadcast()) {
    // Admin path (global_admin or project-lead-of-selected): opens the
    // in-app compose modal that POSTs to /api/team/broadcast.
    wrap.innerHTML = `
      <button type="button" class="btn btn-primary" id="team-broadcast-btn">
        ${label} ${counter}
      </button>
    `;
    document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
  } else {
    // Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
    // the current filter set. href is refreshed in updateBroadcastButton()
    // whenever filters change so the link always reflects what's visible.
    wrap.innerHTML = `
      <a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
        ${label} ${counter}
      </a>
    `;
  }
}

function updateBroadcastButton() {
  buildBroadcastButton();
  const recipients = displayedRecipients();
  const countEl = document.getElementById("team-broadcast-count");
  if (countEl) countEl.textContent = String(recipients.length);
  const btn = document.getElementById("team-broadcast-btn");
  if (!btn) return;
  if (btn.tagName === "BUTTON") {
    (btn as HTMLButtonElement).disabled = recipients.length === 0;
  } else {
    // Anchor (non-admin): regenerate the mailto: href against the current
    // visible recipients, and disable the affordance when empty so a click
    // doesn't open an empty mail composer.
    const a = btn as HTMLAnchorElement;
    if (recipients.length === 0) {
      a.setAttribute("href", "mailto:");
      a.setAttribute("aria-disabled", "true");
      a.style.pointerEvents = "none";
      a.style.opacity = "0.5";
    } else {
      a.setAttribute("href", buildMailtoHref(recipients));
      a.removeAttribute("aria-disabled");
      a.style.pointerEvents = "";
      a.style.opacity = "";
    }
  }
}

// displayedRecipients returns the currently visible users as broadcast
// recipients. Personal placeholder fields are sourced from each user
// (display_name / first_name) and from the membership index when a
// project filter is set (role_on_project = the role on the selected
// project; falls back to first available role).
function displayedRecipients(): BroadcastRecipient[] {
  const filtered = users.filter(
    (u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
  );
  return filtered.map((u) => {
    const m = memberships.find((m) => m.user_id === u.id);
    let role = "";
    if (m) {
      if (activeProjectIDs.size > 0) {
        const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
        if (idx >= 0) role = m.roles[idx];
      } else if (m.roles.length > 0) {
        role = m.roles[0];
      }
    }
    return {
      user_id: u.id,
      email: u.email,
      display_name: u.display_name,
      first_name: firstName(u.display_name),
      role_on_project: role,
    };
  });
}

function onBroadcastClick() {
  const recipients = displayedRecipients();
  const selectedProjectIDs = Array.from(activeProjectIDs);
  // When exactly one project is selected we pass it as project_id so
  // the backend can verify lead-ship on that project. With multi-
  // select we leave project_id null and rely on global_admin (the
  // service rejects non-admin senders without a project_id).
  const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
  const offices = activeOffice === "all" ? [] : [activeOffice];
  const roles = activeRole === "all" ? [] : [activeRole];
  openBroadcastModal({
    recipients,
    projectID,
    projectIDs: selectedProjectIDs,
    offices,
    roles,
  });
}

function memberAsUser(m: DepartmentMember): User | undefined {
  return users.find((u) => u.id === m.user_id);
}

function renderUserCard(u: User): string {
  const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
  const jobTitle = (u.job_title ?? "").trim();
  // t-paliad-223 (#53): per-row select-checkbox. Wrapped in a label so a
  // click on the checkbox cell triggers the toggle; the rest of the card
  // (links, email, etc.) keeps its native behaviour. Selection state
  // mirrored to data-selected so the CSS can highlight the card.
  const selected = selectedUserIDs.has(u.id);
  const selectAria = t("team.selection.toggle_card") || "Kontakt auswählen";
  return `
  <article class="team-card" data-user-id="${esc(u.id)}" data-selected="${selected ? "true" : "false"}">
    <label class="team-card-select" title="${escAttr(selectAria)}">
      <input type="checkbox" class="team-card-select-input" data-user-id="${esc(u.id)}"${selected ? " checked" : ""} aria-label="${escAttr(selectAria)}" />
    </label>
    <div class="team-avatar" aria-hidden="true">${esc(initials(u.display_name))}</div>
    <div class="team-card-body">
      <div class="team-card-name">${esc(u.display_name)}</div>
      ${jobTitle ? `<div class="team-card-role">${esc(roleLabel(jobTitle))}</div>` : ""}
      <div class="team-card-meta">
        <span class="team-office-badge">${ICON_PIN}<span>${esc(officeLabel(u.office))}</span></span>
        ${additional.length ? `<span class="team-office-extra">+ ${additional.map((o) => esc(officeLabel(o))).join(", ")}</span>` : ""}
      </div>
      <a class="team-card-email" href="mailto:${esc(u.email)}">${ICON_MAIL}<span>${esc(u.email)}</span></a>
    </div>
  </article>`;
}

// escAttr is the attribute-context counterpart of esc. Used in title=""
// + aria-label="" where esc()'s div-textContent trick is fine but
// double-quote-escaping is the bit we actually need.
function escAttr(s: string): string {
  return esc(s).replace(/"/g, "&quot;");
}

function renderGroupByOffice(filtered: User[]): string {
  const present = presentOffices();
  const sections = present
    .map((officeKey) => {
      const inOffice = filtered.filter((u) => u.office === officeKey || (u.additional_offices ?? []).includes(officeKey));
      if (!inOffice.length) return "";
      return `
        <section class="team-group">
          <header class="team-group-header">
            <h2>${esc(officeLabel(officeKey))}</h2>
            <span class="team-group-count">${inOffice.length}</span>
          </header>
          <div class="team-grid">${inOffice.map(renderUserCard).join("")}</div>
        </section>`;
    })
    .filter(Boolean);
  // Catch users whose office key isn't in OFFICE_ORDER and isn't present-listed.
  const placedKeys = new Set(present);
  const orphans = filtered.filter((u) => !placedKeys.has(u.office));
  if (orphans.length) {
    sections.push(`
      <section class="team-group">
        <header class="team-group-header">
          <h2>${esc(t("team.group.other") || "Sonstige")}</h2>
          <span class="team-group-count">${orphans.length}</span>
        </header>
        <div class="team-grid">${orphans.map(renderUserCard).join("")}</div>
      </section>`);
  }
  return sections.join("");
}

function renderGroupByDepartment(filtered: User[]): string {
  const filteredIDs = new Set(filtered.map((u) => u.id));
  const sections: string[] = [];

  for (const d of departments) {
    if (activeOffice !== "all" && d.office !== activeOffice) continue;
    const visibleMembers = d.members
      .map(memberAsUser)
      .filter((u): u is User => !!u && filteredIDs.has(u.id));
    if (!visibleMembers.length && !d.lead_display_name) continue;
    const leadHTML = d.lead_display_name
      ? `<div class="team-dept-lead">${esc(t("team.dept.lead") || "Lead")}: <strong>${esc(d.lead_display_name)}</strong>${d.lead_email ? ` <a href="mailto:${esc(d.lead_email)}">${esc(d.lead_email)}</a>` : ""}</div>`
      : "";
    sections.push(`
      <section class="team-group">
        <header class="team-group-header">
          <div>
            <h2>${esc(d.name)}</h2>
            <div class="team-group-sub">${esc(officeLabel(d.office))}</div>
            ${leadHTML}
          </div>
          <span class="team-group-count">${visibleMembers.length}</span>
        </header>
        <div class="team-grid">${visibleMembers.map(renderUserCard).join("")}</div>
      </section>`);
  }

  // Users not in any partner unit get a single "ohne Partner Unit" bucket so
  // they're still discoverable in the directory.
  const inAnyDept = new Set<string>();
  for (const d of departments) for (const m of d.members) inAnyDept.add(m.user_id);
  const orphans = filtered.filter((u) => !inAnyDept.has(u.id));
  if (orphans.length) {
    const heading = t("team.partner_unit.unassigned") || "Ohne Partner Unit";
    sections.push(`
      <section class="team-group team-group-loose">
        <header class="team-group-header">
          <h2>${esc(heading)}</h2>
          <span class="team-group-count">${orphans.length}</span>
        </header>
        <div class="team-grid">${orphans.map(renderUserCard).join("")}</div>
      </section>`);
  }

  return sections.join("");
}

function render() {
  const list = document.getElementById("team-list")!;
  const empty = document.getElementById("team-empty")!;
  const count = document.getElementById("team-count")!;

  const filtered = users.filter(
    (u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
  );

  // t-paliad-223 (#53): prune drop-outs from the explicit selection. The
  // invariant is "selection ⊆ visible"; carrying invisible IDs forward
  // would create stale "12 selected" counters that don't match what the
  // user sees on screen.
  pruneSelectionToVisible(new Set(filtered.map((u) => u.id)));

  count.textContent = `${filtered.length} / ${users.length}`;
  updateBroadcastButton();

  if (filtered.length === 0) {
    list.innerHTML = "";
    empty.style.display = "block";
    renderedUserIDs = [];
    syncMasterCheckbox();
    renderSelectionFooter();
    return;
  }
  empty.style.display = "none";

  list.innerHTML = groupBy === "office"
    ? renderGroupByOffice(filtered)
    : renderGroupByDepartment(filtered);

  // Refresh the DOM-order snapshot Shift-click + master-checkbox rely on.
  renderedUserIDs = Array.from(
    list.querySelectorAll<HTMLElement>(".team-card"),
  ).map((el) => el.dataset.userId || "");

  wireSelectionCheckboxes(list);
  syncMasterCheckbox();
  renderSelectionFooter();
}

// pruneSelectionToVisible drops user_ids from selection that no longer
// match the visible set. Always called from render() before painting so
// the per-row "checked" state and the footer counter stay in sync.
function pruneSelectionToVisible(visible: Set<string>): void {
  const removed: string[] = [];
  for (const id of selectedUserIDs) {
    if (!visible.has(id)) removed.push(id);
  }
  for (const id of removed) selectedUserIDs.delete(id);
  if (removed.length > 0 && lastToggledUserID && !visible.has(lastToggledUserID)) {
    lastToggledUserID = null;
  }
}

// wireSelectionCheckboxes attaches click handlers to every per-row
// checkbox in the freshly-rendered list. Each click toggles the
// underlying selection Set + the data-selected attribute on the card.
// Shift-click extends a contiguous range from the previous toggle to
// the current row using renderedUserIDs as the order reference.
function wireSelectionCheckboxes(list: HTMLElement): void {
  list.querySelectorAll<HTMLInputElement>(".team-card-select-input").forEach((cb) => {
    cb.addEventListener("click", (ev) => {
      const id = cb.dataset.userId || "";
      if (!id) return;
      const checked = cb.checked;
      if ((ev as MouseEvent).shiftKey && lastToggledUserID && lastToggledUserID !== id) {
        applyRangeSelection(lastToggledUserID, id, checked);
      } else {
        if (checked) selectedUserIDs.add(id);
        else selectedUserIDs.delete(id);
      }
      lastToggledUserID = id;
      // Visual + footer refresh without a full re-render (selection
      // changes don't affect the filter set; render() is reserved for
      // filter/data changes to keep typing in the search box fast).
      refreshCardSelectedAttribute();
      syncMasterCheckbox();
      renderSelectionFooter();
    });
  });
}

// applyRangeSelection sets selection state for every user between
// (inclusive) startID and endID in renderedUserIDs order. Mode = the
// final state — checked => add to selection, unchecked => remove.
function applyRangeSelection(startID: string, endID: string, mode: boolean): void {
  const a = renderedUserIDs.indexOf(startID);
  const b = renderedUserIDs.indexOf(endID);
  if (a === -1 || b === -1) {
    // One of the anchors dropped out of the current visible set; fall
    // back to a single-row toggle of the end-id.
    if (mode) selectedUserIDs.add(endID);
    else selectedUserIDs.delete(endID);
    return;
  }
  const [lo, hi] = a <= b ? [a, b] : [b, a];
  for (let i = lo; i <= hi; i++) {
    const id = renderedUserIDs[i];
    if (mode) selectedUserIDs.add(id);
    else selectedUserIDs.delete(id);
  }
}

// refreshCardSelectedAttribute syncs every visible card's data-selected
// + checkbox.checked to the canonical Set, without a full re-render.
function refreshCardSelectedAttribute(): void {
  const list = document.getElementById("team-list");
  if (!list) return;
  list.querySelectorAll<HTMLElement>(".team-card").forEach((card) => {
    const id = card.dataset.userId || "";
    const selected = selectedUserIDs.has(id);
    card.dataset.selected = selected ? "true" : "false";
    const cb = card.querySelector<HTMLInputElement>(".team-card-select-input");
    if (cb) cb.checked = selected;
  });
}

// renderSelectionFooter mounts (or hides) the sticky footer that takes
// over the broadcast action when ≥ 1 row is checked. The footer lives
// outside the main content tree so it can be position: fixed without
// fighting any of the existing layout rules.
function renderSelectionFooter(): void {
  let footer = document.getElementById("team-selection-footer") as HTMLDivElement | null;
  const n = selectedUserIDs.size;
  if (n === 0) {
    if (footer) footer.style.display = "none";
    document.body.classList.remove("team-has-selection");
    return;
  }
  if (!footer) {
    footer = document.createElement("div");
    footer.id = "team-selection-footer";
    footer.className = "team-selection-footer";
    document.body.appendChild(footer);
  }
  const countLabel = (t("team.selection.count") || "{n} ausgewählt").replace(
    "{n}",
    String(n),
  );
  const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
  // t-paliad-244: mirror buildBroadcastButton() so the bottom send button
  // behaves the same as the filter-bar one. Admin (canBroadcast) opens the
  // compose modal; non-admin gets a native mailto: anchor pre-filled with
  // the explicit selection.
  const adminPath = canBroadcast();
  const sendAction = adminPath
    ? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
    : `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
  footer.innerHTML = `
    <span class="team-selection-count">${esc(countLabel)}</span>
    <button type="button" class="btn-secondary btn-small" id="team-selection-clear">
      ${esc(t("team.selection.clear") || "Auswahl aufheben")}
    </button>
    ${sendAction}
  `;
  footer.style.display = "";
  document.body.classList.add("team-has-selection");
  document.getElementById("team-selection-clear")?.addEventListener("click", () => {
    selectedUserIDs.clear();
    lastToggledUserID = null;
    refreshCardSelectedAttribute();
    syncMasterCheckbox();
    renderSelectionFooter();
  });
  if (adminPath) {
    document.getElementById("team-selection-send")?.addEventListener("click", () => {
      onBroadcastFromSelection();
    });
  }
  // Anchor path has no click handler — native href open is the action.
}

// selectedRecipients maps the explicit selection Set into the
// BroadcastRecipient shape openBroadcastModal expects. Mirrors the
// role-resolution rules of displayedRecipients() (active project
// filter wins; falls back to first available role).
function selectedRecipients(): BroadcastRecipient[] {
  const out: BroadcastRecipient[] = [];
  for (const id of selectedUserIDs) {
    const u = users.find((u) => u.id === id);
    if (!u) continue;
    const m = memberships.find((m) => m.user_id === u.id);
    let role = "";
    if (m) {
      if (activeProjectIDs.size > 0) {
        const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
        if (idx >= 0) role = m.roles[idx];
      } else if (m.roles.length > 0) {
        role = m.roles[0];
      }
    }
    out.push({
      user_id: u.id,
      email: u.email,
      display_name: u.display_name,
      first_name: firstName(u.display_name),
      role_on_project: role,
    });
  }
  return out;
}

function onBroadcastFromSelection(): void {
  const recipients = selectedRecipients();
  if (recipients.length === 0) return;
  const selectedProjectIDs = Array.from(activeProjectIDs);
  // Same scope-resolution as displayedRecipients/onBroadcastClick: pass
  // project_id only when exactly one is selected so the server can
  // verify lead-ship; multi-project relies on global_admin.
  const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
  const offices = activeOffice === "all" ? [] : [activeOffice];
  const roles = activeRole === "all" ? [] : [activeRole];
  openBroadcastModal({
    recipients,
    projectID,
    projectIDs: selectedProjectIDs,
    offices,
    roles,
  });
}

// syncMasterCheckbox refreshes the master "select all visible" checkbox
// to one of three states: empty / partial / full. The HTML element lives
// in team.tsx (#team-select-master); when missing (older shells) the
// helper no-ops so the page still works.
function syncMasterCheckbox(): void {
  const master = document.getElementById("team-select-master") as HTMLInputElement | null;
  if (!master) return;
  const visible = renderedUserIDs.length;
  if (visible === 0) {
    master.checked = false;
    master.indeterminate = false;
    master.disabled = true;
    return;
  }
  master.disabled = false;
  let selectedHere = 0;
  for (const id of renderedUserIDs) {
    if (selectedUserIDs.has(id)) selectedHere++;
  }
  master.checked = selectedHere === visible;
  master.indeterminate = selectedHere > 0 && selectedHere < visible;
}

function onMasterToggle(): void {
  const master = document.getElementById("team-select-master") as HTMLInputElement | null;
  if (!master) return;
  const checked = master.checked;
  for (const id of renderedUserIDs) {
    if (checked) selectedUserIDs.add(id);
    else selectedUserIDs.delete(id);
  }
  lastToggledUserID = checked && renderedUserIDs.length > 0 ? renderedUserIDs[renderedUserIDs.length - 1] : null;
  refreshCardSelectedAttribute();
  syncMasterCheckbox();
  renderSelectionFooter();
}

function initToggle() {
  const container = document.querySelector<HTMLElement>(".team-toggle")!;
  container.addEventListener("click", (e) => {
    const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
    if (!btn) return;
    const next = btn.dataset.group as "office" | "department" | undefined;
    if (!next || next === groupBy) return;
    groupBy = next;
    container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
    btn.classList.add("active");
    render();
  });
}

function initSearch() {
  const input = document.getElementById("team-search") as HTMLInputElement;
  input.addEventListener("input", () => {
    searchQuery = input.value;
    render();
  });
}

document.addEventListener("DOMContentLoaded", () => {
  initI18n();
  initSidebar();
  initSearch();
  initToggle();
  // t-paliad-223 (#53): master checkbox toggles every visible row.
  document.getElementById("team-select-master")?.addEventListener("change", onMasterToggle);
  onLangChange(() => {
    buildOfficeFilters();
    buildRoleFilters();
    render();
  });
  loadAll();
});
