/**
 * Collapse/expand of category groups in the hierarchical reports (real-vs-real
 * Compare, Budget-vs-Actual, trends over time) — issue #254/#265, generalised to
 * every hierarchy level in issue #288.
 *
 * Pure helpers only; the React state + persistence glue lives in the
 * `useCollapsibleGroups` hook.
 *
 * Visibility is decided by an ANCESTOR WALK, not by row adjacency: a row is
 * hidden iff *any* of its ancestors is collapsed. We climb `parentKeyOf` from a
 * row until we reach a root (null), an ancestor outside this section (⇒ visible),
 * or a collapsed ancestor (⇒ hidden). This is independent of the order rows
 * arrive in — the fix for the v1 adjacency algorithm, which assumed a
 * depth-first order the budget grid (level-ordered) does not emit.
 *
 * The accessors are supplied by each consumer so every table can key + climb the
 * exact hierarchy it renders. Keys are prefixed `p:` so they are injective: a
 * parent subtotal row and its own "(direct)" leaf share a category id but the
 * leaf is never a parent, so only the subtotal is indexed — the leaf simply
 * carries the parent's key via `parentKeyOf` and is hidden with it.
 */

/**
 * Cycle guard for the ancestor walk. The standard taxonomy is three levels deep;
 * this generous finite cap only exists so a corrupt cyclic `parent_id` (DB
 * corruption) can never loop forever. Any real chain terminates in a few hops.
 */
export const MAX_TREE_DEPTH = 64;

/** How the collapse logic reads hierarchy position + identity off a row. */
export interface CollapseAccessors<T> {
  /** Stable per-parent key, injective across the section: `p:<categoryId>`. */
  keyOf: (row: T) => string;
  /** Key of this row's parent (`p:<parent_id>`), or null at a root. */
  parentKeyOf: (row: T) => string | null;
  /** True when the row is a group header / subtotal (has children). */
  isParent: (row: T) => boolean;
  /** Indentation depth; 0 = top-level. Drives indentation + aria-level ONLY. */
  depthOf: (row: T) => number;
}

/**
 * Keeps every row that is currently visible given which groups are collapsed. A
 * row is dropped iff any ancestor (walking `parentKeyOf`) is collapsed; a missing
 * ancestor (parent lives in another section) leaves the row visible.
 */
export function visibleRows<T>(
  rows: T[],
  acc: CollapseAccessors<T>,
  isCollapsed: (row: T) => boolean,
): T[] {
  // Index ONLY parents: a "(direct)" leaf shares its parent's category id but is
  // never a parent itself, so it never clobbers the subtotal it points at.
  const parents = new Map<string, T>();
  for (const r of rows) if (acc.isParent(r)) parents.set(acc.keyOf(r), r);

  const hiddenByAncestor = (row: T): boolean => {
    let key = acc.parentKeyOf(row);
    let hops = 0;
    while (key !== null && hops++ < MAX_TREE_DEPTH) {
      const parent = parents.get(key);
      if (parent === undefined) return false; // ancestor outside this section → visible
      if (isCollapsed(parent)) return true;
      key = acc.parentKeyOf(parent);
    }
    return false;
  };

  return rows.filter((r) => !hiddenByAncestor(r));
}

/** Keys of every collapsible group (all parent rows, any depth), in encounter order. */
export function collapsibleKeys<T>(rows: T[], acc: CollapseAccessors<T>): string[] {
  const keys: string[] = [];
  for (const row of rows) {
    if (acc.isParent(row)) keys.push(acc.keyOf(row));
  }
  return keys;
}

/**
 * Polarity of the collapse state. The persisted Set holds the keys whose state
 * DIFFERS from the default, which lets one hook serve both polarities without a
 * seed:
 *  - `defaultExpanded: false` (reports, budget) ⇒ Set = the *expanded* keys, so
 *    an empty Set means everything collapsed (the reports' current behaviour).
 *  - `defaultExpanded: true` (category trees) ⇒ Set = the *collapsed* keys, so an
 *    empty Set means everything expanded.
 */
export function isCollapsedFor(
  flipped: Set<string>,
  key: string,
  defaultExpanded: boolean,
): boolean {
  const isFlipped = flipped.has(key);
  return defaultExpanded ? isFlipped : !isFlipped;
}

/**
 * Parses the persisted value into the flipped-key set (see `isCollapsedFor`). An
 * absent or corrupt value yields an empty set — i.e. the default collapse state.
 */
export function parseStoredExpanded(raw: string | null): Set<string> {
  if (!raw) return new Set();
  try {
    const parsed: unknown = JSON.parse(raw);
    if (Array.isArray(parsed)) {
      return new Set(parsed.filter((k): k is string => typeof k === "string"));
    }
  } catch {
    // Corrupt value: fall back to the default state.
  }
  return new Set();
}

/** Serialises the flipped-key set for persistence. */
export function serializeExpanded(flipped: Set<string>): string {
  return JSON.stringify([...flipped]);
}
