// screens-org-directory.jsx — the DIRECTORY view of the Org Chart screen.
//
// BUILD_40 §6 orders this fourth but says to ship it FIRST, because it is the
// smallest piece and the only one that produces a visible win without new
// tables or a ruling. It is the design's "Outline Rail + Profile" pair: a
// searchable people list on the left, one person's profile on the right.
//
// WHAT IT IS NOT. It is not the SVG tree and it is not the Group Lens. Both
// come later and both live in their own files.
//
// ONE ROSTER (BUILD_40 §4). VaultOrg.roster() decides WHO is in the directory.
// VAULT_FIRM.PEOPLE_BY_ID supplies the FIELDS, because roster() returns
// {id,name,role,active,inScope} and the profile needs supId and title as well.
// That is not a second person list -- membership has exactly one owner, and it
// is roster(). Do not replace the roster() call with a PEOPLE.filter().
//
// POSITION HISTORY READS people_positions (sql_194-197), WITH effective_date AS
// THE FALLBACK. The note below is why this was a one-line stub; that reason
// expired on 2026-09-01, when position history became writable.
//
// probe_90d (2026-08-26): 49 people_history rows across 46 people, all between
// 2026-07-01 and 2026-08-26 -- the arrivals import and the re-key, not anybody's
// career. A timeline drawn from that table would show "changed 8/26" against
// nearly the whole firm and read as though everyone was just promoted, so it is
// NOT the source here. effective_date is, and Brian's ruling is that those dates
// go in by hand for now (sql_154 exposed the column; the Org screen's edit modal
// writes it). Anyone without one still gets the design's own line, "Promotion
// dates not on file." -- which is true, rather than a date invented from an
// audit row.
//
// WHAT CHANGED 2026-09-01. people_positions is now a real table with real rows:
// a promotion closes one and opens the next, and the Org Chart's edit modal
// writes them. So this panel draws the actual sequence rather than one line.
// people_history is STILL not the source and must not become it -- the
// objection above holds, it is an audit log of imports.
//
// A row flagged is_seed was created from the person's current title when the
// table was populated and its dates were never typed by anyone, so it is
// labelled rather than presented as fact.

(function () {
  "use strict";

  var useState = React.useState, useMemo = React.useMemo;

  function firm() { return window.VAULT_FIRM || {}; }
  function byId() { return firm().PEOPLE_BY_ID || {}; }

  function initialsOf(p) {
    if (!p) return "?";
    if (p.initials) return p.initials;
    var parts = String(p.name || p.id || "").trim().split(/\s+/);
    if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
    return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
  }

  // Everyone the viewer may see, as ids.
  //
  // `module` is null today, which means every ACTIVE person firm-wide, because
  // the Org Chart is still admin-only (app.jsx:532). It is a PROP rather than a
  // hardcoded null so that narrowing it to one module later is a caller change,
  // not a surgery on this file -- and so the harness can mount both ways and
  // prove the scope actually confines the screen.
  function directoryIds(module) {
    if (!window.VaultOrg || typeof window.VaultOrg.roster !== "function") return [];
    return window.VaultOrg.roster(module || null).map(function (r) { return r.id; });
  }

  // supId -> [personId].
  //
  // THE CONFINEMENT IS THE ITERATION, not a filter inside it. This walks the
  // ids the roster returned and nothing else, so somebody the roster excluded
  // can never appear as a child -- including the cross-module case, where an
  // out-of-scope analyst reports to an in-scope lead. A `!allow[supId]` clause
  // was here and was removed on 2026-08-26: it read as a second guard but every
  // child is already in scope by construction, so it could never fire. The
  // harness proved it (its mutant would not die) rather than a review noticing.
  function buildChildren(ids) {
    var P = byId();
    var kids = Object.create(null);
    ids.forEach(function (id) {
      var p = P[id];
      var s = p && p.supId;
      if (!s || s === id) return;
      (kids[s] = kids[s] || []).push(id);
    });
    Object.keys(kids).forEach(function (k) {
      kids[k].sort(function (a, b) {
        return String((P[a] || {}).name || a).localeCompare(String((P[b] || {}).name || b));
      });
    });
    return kids;
  }

  // Everyone below a person. CYCLE GUARD IS NOT OPTIONAL: screens-org.jsx's
  // PersonDetail walks the chain with no guard at all, and two people pointing
  // at each other hangs the tab. Nothing in the schema prevents that pair.
  function descendantCount(rootId, kids) {
    var seen = Object.create(null);
    // Seed with the root. In a cycle the walk comes back around, and without
    // this a person is counted as their own descendant.
    seen[rootId] = true;
    var stack = (kids[rootId] || []).slice();
    var n = 0;
    while (stack.length) {
      var id = stack.pop();
      if (seen[id]) continue;
      seen[id] = true;
      n++;
      var next = kids[id] || [];
      for (var i = 0; i < next.length; i++) if (!seen[next[i]]) stack.push(next[i]);
    }
    return n;
  }

  // Manager chain, nearest first.
  //
  // TWO GUARDS, both earned:
  //  1. CYCLES. screens-org.jsx's PersonDetail walks this with no guard, and two
  //     people pointing at each other hangs the tab. Nothing in the schema
  //     forbids that pair.
  //  2. OUT-OF-SCOPE PARENTS. probe_90b (2026-08-26) found live rows whose
  //     sub_team_lead_id resolved to an INACTIVE person. Walking through them
  //     puts a departed employee in the breadcrumb and prints "Reports to
  //     <somebody who left>". The walk stops at the break and REPORTS it --
  //     silently substituting a grandparent would hide the drift, which is the
  //     one thing this screen exists to expose.
  function chainUp(id, allow) {
    var P = byId();
    var out = [];
    var seen = Object.create(null);
    var cur = P[id] && P[id].supId;
    var broken = null;
    while (cur && !seen[cur]) {
      seen[cur] = true;
      var m = P[cur];
      if (!m || !allow || !allow[cur]) { broken = cur; break; }
      out.push(m);
      cur = m.supId;
    }
    return { chain: out, broken: broken };
  }

  // ALWAYS THROUGH VaultDate (BRANDING §5). It resolves the viewer's zone from
  // notification_prefs.tz and applies it LAST, so a caller cannot override it.
  // A bare toLocaleDateString here would disagree with every other screen by a
  // day at 05:00Z. A date-only column still needs it -- "2026-03-04" parsed by
  // the browser is UTC midnight, which is the 3rd on the US west coast.
  function formatSince(value) {
    if (!value) return null;
    if (typeof window.VaultDate === "function") {
      return window.VaultDate(value, { month: "short", day: "numeric", year: "numeric" });
    }
    return null;
  }

  function moduleLabel(handle) {
    if (!handle) return null;
    var st = (firm().SUBTEAMS || []).find(function (s) { return s.lead === handle; });
    if (st && st.label) return st.label + " Module";
    var head = byId()[handle];
    return head ? head.name : handle;
  }

  // ---------------------------------------------------------------- rail row
  function RailRow(props) {
    var p = props.person;
    var on = props.selected;
    return (
      <div
        data-org-row={p.id}
        onClick={props.onClick}
        title={p.title || p.role || ""}
        style={{
          display: "flex", alignItems: "center", gap: 8,
          padding: "6px 8px",
          borderRadius: "var(--r-ctl)",
          cursor: "pointer",
          background: on ? "var(--accent-soft)" : "transparent",
        }}
      >
        <span style={{
          width: 22, height: 22, flex: "none", borderRadius: "var(--r-pill)",
          background: on ? "var(--accent)" : "var(--surface-3)",
          color: on ? "var(--accent-ink)" : "var(--ink-3)",
          display: "grid", placeItems: "center",
          fontSize: 9.5, fontWeight: "var(--w-bold)",
        }}>{initialsOf(p)}</span>
        <span style={{
          flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis",
          whiteSpace: "nowrap",
          fontSize: "var(--t-body)",
          fontWeight: on ? "var(--w-medium)" : "var(--w-regular)",
          color: "var(--ink)",
        }}>{p.name}</span>
        {props.groups && props.groups.length ? (
          <span style={{ display: "inline-flex", gap: 3, alignItems: "center", flex: "none" }}>
            {props.groups.map(function (g) {
              return <span key={g.id} title={g.name} style={{
                width: 6, height: 6, borderRadius: "var(--r-pill)",
                background: "var(" + g.colorToken + ")" }}/>;
            })}
          </span>
        ) : null}
        {props.reports > 0 ? (
          <span style={{
            flex: "none", fontSize: "var(--t-micro)",
            fontWeight: "var(--w-medium)", color: "var(--muted)",
          }}>{props.reports}</span>
        ) : null}
      </div>
    );
  }

  // --------------------------------------------------------------- stat tile
  function StatTile(props) {
    return (
      <div style={{
        padding: "10px 12px",
        background: "var(--canvas)",
        border: "1px solid var(--line-2)",
        borderRadius: "var(--r-ctl)",
      }}>
        <div className="v-kicker" style={{ marginBottom: 3 }}>{props.label}</div>
        <div style={{
          fontSize: 18, fontWeight: "var(--w-medium)", color: "var(--ink)",
          lineHeight: 1.1,
        }}>{props.value}</div>
        {props.sub ? (
          <div style={{ fontSize: "var(--t-micro)", color: "var(--muted-2)", marginTop: 2 }}>
            {props.sub}
          </div>
        ) : null}
      </div>
    );
  }

  // ----------------------------------------------------------------- profile
  function Profile(props) {
    var p = props.person;
    var kids = props.kids;
    var P = byId();

    if (!p) {
      return (
        <div style={{ padding: "22px 28px" }}>
          <window.EmptyState
            title="Nothing selected."
            hint="Pick someone from the list to see their profile."
          />
        </div>
      );
    }

    var walk = chainUp(p.id, props.allow);
    var chain = walk.chain;
    var manager = chain.length ? chain[0] : null;
    var reports = (kids[p.id] || []).map(function (id) { return P[id]; }).filter(Boolean);
    var desc = descendantCount(p.id, kids);
    var mod = moduleLabel(p.team);
    var since = formatSince(p.effectiveDate);

    // The real sequence. Loaded per person; an empty or failed load falls back
    // to the single current title, which is what this panel showed before there
    // was anything else to show.
    var histState = useState(null);
    var history = histState[0], setHistory = histState[1];
    React.useEffect(function () {
      setHistory(null);
      if (!p || !p.id || !window.VaultAPI || !window.VaultAPI.listPersonPositions) return;
      var cancelled = false;
      window.VaultAPI.listPersonPositions(p.id)
        .then(function (rows) { if (!cancelled) setHistory(rows || []); })
        .catch(function () { if (!cancelled) setHistory([]); });
      // Same as the Tree/Map drawer: this effect is keyed on the person id,
      // which does not change when their history does, so a write elsewhere
      // needs to say so.
      var onWrote = function (e) {
        if (!e || !e.detail || e.detail.personId === p.id) {
          window.VaultAPI.listPersonPositions(p.id)
            .then(function (rows) { if (!cancelled) setHistory(rows || []); })
            .catch(function () {});
        }
      };
      window.addEventListener("vault:positions-updated", onWrote);
      return function () { cancelled = true; window.removeEventListener("vault:positions-updated", onWrote); };
    }, [p && p.id]);

    return (
      <div style={{ padding: "22px 28px", maxWidth: "56rem" }}>

        {/* Breadcrumbs up the chain, root first. */}
        {chain.length ? (
          <div style={{
            fontSize: "var(--t-small)", color: "var(--muted)", marginBottom: 14,
            display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center",
          }}>
            {chain.slice().reverse().map(function (m) {
              return (
                <React.Fragment key={m.id}>
                  <a href="#" onClick={function (e) { e.preventDefault(); props.onPick(m.id); }}
                     style={{ color: "var(--muted)" }}>{m.name}</a>
                  <span style={{ color: "var(--muted-2)" }}>{"\u203A"}</span>
                </React.Fragment>
              );
            })}
            <span style={{ color: "var(--ink)", fontWeight: "var(--w-medium)" }}>{p.name}</span>
          </div>
        ) : null}

        <div style={{ display: "flex", gap: 16, alignItems: "center", marginBottom: 18 }}>
          <span style={{
            width: 56, height: 56, flex: "none", borderRadius: "var(--r-pill)",
            background: "var(--accent)", color: "var(--accent-ink)",
            display: "grid", placeItems: "center",
            fontSize: 19, fontWeight: "var(--w-bold)",
          }}>{initialsOf(p)}</span>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{
              fontSize: "var(--t-page)", fontWeight: "var(--w-medium)",
              letterSpacing: "-.02em", color: "var(--ink)",
            }}>{p.name}</div>
            <div style={{ fontSize: "var(--t-body-lg)", color: "var(--ink-3)", marginTop: 2 }}>
              {p.title || p.role || "\u2014"}
              {manager ? (
                <React.Fragment>
                  <span style={{ color: "var(--muted)" }}>{" \u00B7 Reports to "}</span>
                  <a href="#" onClick={function (e) { e.preventDefault(); props.onPick(manager.id); }}>
                    {manager.name}
                  </a>
                </React.Fragment>
              ) : null}
              {(!manager && walk.broken) ? (
                <span style={{ color: "var(--muted)" }}>
                  {" \u00B7 Manager on file is inactive or missing"}
                </span>
              ) : null}
            </div>
          </div>
          {props.isAdmin && props.onEditPerson ? (
            <button className="btn sm" onClick={function () { props.onEditPerson(p.id); }}>
              Edit
            </button>
          ) : null}
        </div>

        <div style={{
          display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(9rem, 1fr))",
          gap: 10, marginBottom: 22,
        }}>
          <StatTile label="Direct Reports" value={reports.length}/>
          <StatTile label="Total Below" value={desc}/>
          <StatTile label="Module" value={mod || "\u2014"}
                    sub={mod ? null : "No module on file."}/>
          <StatTile label="Handle" value={p.id}/>
        </div>

        {/* POSITION HISTORY. See the file header -- this is a stub on purpose. */}
        <div style={{ marginBottom: 22 }}>
          <div className="v-kicker" style={{ marginBottom: 8 }}>Position History</div>
          {history && history.length ? (
            history.map(function (h, i) {
              return (
                <div key={h.id} style={{ display: "flex", gap: 8, alignItems: "baseline",
                              fontSize: "var(--t-body)", marginBottom: 8 }}>
                  {/* The open row is the accent; everything closed is muted, so
                      the current position reads at a glance without a label. */}
                  <span style={{ width: 8, height: 8, flex: "none", borderRadius: "50%",
                                 marginTop: 6,
                                 background: h.effectiveTo ? "var(--line-strong)" : "var(--accent)" }}/>
                  <div>
                    <div style={{ fontWeight: "var(--w-medium)",
                                  color: h.effectiveTo ? "var(--ink-3)" : "var(--ink)" }}>
                      {h.title || "\u2014"}
                    </div>
                    <div style={{ fontSize: "var(--t-micro)", color: "var(--muted)" }}>
                      {h.effectiveFrom
                        ? (formatSince(h.effectiveFrom) + (h.effectiveTo
                            ? " \u2013 " + formatSince(h.effectiveTo) : " \u2013 now"))
                        : "Dates not on file"}
                      {h.isSeed ? " \u00b7 not verified" : ""}
                    </div>
                  </div>
                </div>
              );
            })
          ) : (
            <div style={{ display: "flex", gap: 8, alignItems: "baseline",
                          fontSize: "var(--t-body)" }}>
              <span style={{ width: 8, height: 8, flex: "none", borderRadius: "50%",
                             background: "var(--accent)" }}/>
              <div>
                <div style={{ fontWeight: "var(--w-medium)", color: "var(--ink)" }}>
                  {p.title || p.role || "\u2014"}
                </div>
                {since ? (
                  <div style={{ fontSize: "var(--t-micro)", color: "var(--muted)" }}>
                    {"Since " + since}
                  </div>
                ) : (
                  <div style={{ fontSize: "var(--t-micro)", color: "var(--muted-2)",
                                fontStyle: "italic" }}>
                    Promotion dates not on file.
                  </div>
                )}
              </div>
            </div>
          )}
        </div>

        <div>
          <div className="v-kicker" style={{ marginBottom: 8 }}>Supervised Individuals</div>
          {reports.length === 0 ? (
            <div className="v-empty">Nobody reports to this person.</div>
          ) : (
            <div style={{ display: "flex", flexDirection: "column" }}>
              {reports.map(function (r) {
                return (
                  <div key={r.id}
                       data-org-report={r.id}
                       onClick={function () { props.onPick(r.id); }}
                       style={{
                         display: "flex", alignItems: "center", gap: 9,
                         padding: "6px 4px",
                         borderBottom: "1px solid var(--line-2)",
                         cursor: "pointer",
                       }}>
                    <span style={{
                      width: 24, height: 24, flex: "none", borderRadius: "var(--r-pill)",
                      background: "var(--surface-3)", color: "var(--ink-3)",
                      display: "grid", placeItems: "center",
                      fontSize: 10, fontWeight: "var(--w-bold)",
                    }}>{initialsOf(r)}</span>
                    <span style={{ fontSize: "var(--t-body)", fontWeight: "var(--w-medium)",
                                   color: "var(--ink)" }}>{r.name}</span>
                    <span style={{ fontSize: "var(--t-micro)", color: "var(--muted)",
                                   flex: 1, minWidth: 0, whiteSpace: "nowrap",
                                   overflow: "hidden", textOverflow: "ellipsis" }}>
                      {r.title || r.role || ""}
                    </span>
                  </div>
                );
              })}
            </div>
          )}
        </div>
      </div>
    );
  }

  // ------------------------------------------------------------------- view
  function OrgDirectoryView(props) {
    var isAdmin = !!props.isAdmin;
    var onEditPerson = props.onEditPerson;
    var tick = props.orgTick;

    var [query, setQuery] = useState("");
    // OPENS ON SOMEBODY, NOT ON AN EMPTY PANE. A directory whose right half is
    // blank until you click looks broken. The default is the ROOT OF THE SCOPE,
    // derived -- firm-wide that is David Harvey; scoped to one module it is that
    // module's lead. Never a hardcoded handle, for the same reason the tree's
    // root is derived: under a module-scoped roster a literal "dharvey" is not
    // in the set at all.
    var [picked, setPicked] = useState(null);

    var ids = useMemo(function () { return directoryIds(props.module); }, [tick, props.module]);
    var kids = useMemo(function () { return buildChildren(ids); }, [ids]);
    // The membership set as a lookup. Every walk -- up or down -- is confined
    // to it, so nobody the roster excluded can appear anywhere on the screen.
    var allow = useMemo(function () {
      var m = Object.create(null);
      ids.forEach(function (id) { m[id] = true; });
      return m;
    }, [ids]);

    var P = byId();

    // TWO FILTERS THAT INTERSECT: the search box and the group selected on the
    // Org Chart's chip row. `memberSet` arrives as a prop rather than being
    // fetched here, so the Tree, the Map and this list are all reading ONE
    // selection -- a second groups fetch in this file would be the same fact
    // in two places with nothing reconciling them.
    var memberSet = props.memberSet || null;
    var rows = useMemo(function () {
      var q = query.trim().toLowerCase();
      return ids
        .map(function (id) { return P[id]; })
        .filter(Boolean)
        .filter(function (p) { return !memberSet || memberSet[p.id]; })
        .filter(function (p) {
          if (!q) return true;
          return (String(p.name || "") + " " + String(p.title || "") + " " + String(p.id || ""))
            .toLowerCase().indexOf(q) !== -1;
        })
        .sort(function (a, b) { return String(a.name).localeCompare(String(b.name)); });
    }, [ids, query, tick, memberSet]);

    var defaultId = useMemo(function () {
      if (!ids.length) return null;
      // Under a group lens the root is usually NOT a member, and defaulting to
      // somebody the rail no longer lists reads as a stuck pane.
      if (memberSet) {
        var m = ids.filter(function (id) { return memberSet[id]; });
        if (!m.length) return null;
        m.sort(function (a, b) {
          return String((P[a] || {}).name || a).localeCompare(String((P[b] || {}).name || b));
        });
        return m[0];
      }
      var allow = Object.create(null);
      ids.forEach(function (id) { allow[id] = true; });
      var roots = ids.filter(function (id) {
        var sup = P[id] && P[id].supId;
        return !sup || !allow[sup] || sup === id;
      });
      if (!roots.length) return ids[0];
      // Ties break on descendant count so a stray unparented row cannot take
      // the pane from the actual head of the tree.
      roots.sort(function (a, b) { return descendantCount(b, kids) - descendantCount(a, kids); });
      return roots[0];
    }, [ids, kids, memberSet]);

    // The selection must survive a filter that no longer contains it -- clearing
    // it on every keystroke makes the profile flash empty while typing.
    var selectedId = (picked && P[picked]) ? picked : defaultId;

    return (
      <div style={{
        display: "grid",
        gridTemplateColumns: "18rem minmax(0, 1fr)",
        border: "1px solid var(--line)",
        borderRadius: "var(--r-card)",
        background: "var(--surface)",
        overflow: "hidden",
        minHeight: "32rem",
      }}>

        <div style={{
          borderRight: "1px solid var(--line)",
          display: "flex", flexDirection: "column", minHeight: 0,
        }}>
          <div style={{ padding: "10px 10px 8px", borderBottom: "1px solid var(--line-2)" }}>
            <window.VaultSearch
              value={query}
              onChange={setQuery}
              size="sm"
              placeholder={"Search people\u2026"}
              label="Search people"
              style={{ minWidth: 0, width: "100%" }}
            />
            <div style={{ marginTop: 6, fontSize: "var(--t-micro)", color: "var(--muted-2)" }}>
              {props.groupLabel
                ? rows.length + " in " + props.groupLabel
                : (rows.length + (rows.length === ids.length ? " people" : " of " + ids.length))}
            </div>
          </div>
          <div style={{ flex: 1, overflow: "auto", padding: "6px 6px 24px", maxHeight: "34rem" }}>
            {rows.length === 0 ? (
              <div className="v-empty">
                {props.groupLabel && !query.trim()
                  ? "Nobody is in this group yet."
                  : "Nobody matches that search."}
              </div>
            ) : rows.map(function (p) {
              return (
                <RailRow
                  key={p.id}
                  person={p}
                  groups={props.groupsOf ? props.groupsOf(p.id) : null}
                  reports={(kids[p.id] || []).length}
                  selected={selectedId === p.id}
                  onClick={function () { setPicked(p.id); }}
                />
              );
            })}
          </div>
        </div>

        <div style={{ overflow: "auto", minHeight: 0, maxHeight: "40rem" }}>
          <Profile
            person={selectedId ? P[selectedId] : null}
            kids={kids}
            allow={allow}
            isAdmin={isAdmin}
            onEditPerson={onEditPerson}
            onPick={setPicked}
          />
        </div>
      </div>
    );
  }

  window.OrgDirectoryView = OrgDirectoryView;
})();
