/**
 * v0.34 W7 — per-op graph-traversal metrics.
 *
 * Pure-function module — fully unit-testable, no engine needed.
 *
 * Metrics:
 *  - nodeSetJaccard: |A ∩ B| / |A ∪ B| over (file, line, symbol) tuples.
 *    Right metric for code_blast / code_flow node sets (NOT page-slug
 *    Jaccard — that's wrong for graph ops, per Codex finding #3 from the
 *    plan-review).
 *  - depthGroupStability: 1 if every node lands in the same depth bucket
 *    across runs; degraded by 1/N per displaced node where N = |union|.
 *  - truncationMatch: 1 if both runs hit the same truncation enum value,
 *    0 otherwise. Discrete; reported alongside Jaccard.
 *  - adjustedRandIndex: cluster-membership stability for code_cluster_get
 *    via Adjusted Rand Index. Returns [-1, 1]; 1 = identical clusterings,
 *    0 = no agreement beyond chance, negative = worse than chance. v0.34.1
 *    consumer (when clusters ship).
 */

export interface GraphNode {
  symbol: string;
  /** Optional location coords for tighter dedup. */
  file?: string;
  line?: number;
}

/** Canonicalize a node to a hashable key for set ops. */
function nodeKey(n: GraphNode): string {
  return `${n.symbol}::${n.file ?? ''}::${n.line ?? ''}`;
}

/**
 * Set-Jaccard over node tuples. Returns NaN on empty union (degenerate
 * case — caller decides how to score "both runs returned nothing").
 */
export function nodeSetJaccard(a: readonly GraphNode[], b: readonly GraphNode[]): number {
  const aSet = new Set(a.map(nodeKey));
  const bSet = new Set(b.map(nodeKey));
  if (aSet.size === 0 && bSet.size === 0) return Number.NaN;
  let intersection = 0;
  for (const k of aSet) if (bSet.has(k)) intersection += 1;
  const union = aSet.size + bSet.size - intersection;
  return union === 0 ? 0 : intersection / union;
}

/**
 * Did every node land in the same depth bucket across the two runs? A
 * node that moved from depth 1 → depth 2 between runs counts as one
 * displaced node. Returns 1 - (displaced / union). Returns 1.0 when
 * both runs are empty.
 */
export function depthGroupStability(
  a: ReadonlyArray<{ depth: number; nodes: readonly GraphNode[] }>,
  b: ReadonlyArray<{ depth: number; nodes: readonly GraphNode[] }>,
): number {
  const aMap = new Map<string, number>();
  const bMap = new Map<string, number>();
  for (const g of a) for (const n of g.nodes) aMap.set(nodeKey(n), g.depth);
  for (const g of b) for (const n of g.nodes) bMap.set(nodeKey(n), g.depth);
  const union = new Set<string>([...aMap.keys(), ...bMap.keys()]);
  if (union.size === 0) return 1.0;
  let displaced = 0;
  for (const k of union) {
    const da = aMap.get(k);
    const db = bMap.get(k);
    if (da !== db) displaced += 1;
  }
  return 1 - displaced / union.size;
}

/**
 * Boolean truncation-enum match. 1 if both runs hit the same truncation
 * value (none / max_nodes / depth_cap / both); 0 otherwise.
 */
export function truncationMatch(
  a: string | undefined,
  b: string | undefined,
): number {
  return (a ?? 'none') === (b ?? 'none') ? 1 : 0;
}

/**
 * Adjusted Rand Index for two clusterings of the same item set.
 *
 * `assignmentA` and `assignmentB` are arrays of cluster labels indexed
 * by item position. Items must appear in the same order across both
 * inputs (caller is responsible for the ordering).
 *
 * Returns a value in [-1, 1]:
 *   1  = identical clusterings
 *   0  = clusterings agree no more than chance
 *   <0 = clusterings agree less than chance (rare)
 *
 * Implementation: standard pairwise-agreement formulation. O(n²) on
 * pair counts but n is typically <500 chunks so this is fine.
 */
export function adjustedRandIndex(
  assignmentA: readonly (string | number)[],
  assignmentB: readonly (string | number)[],
): number {
  if (assignmentA.length !== assignmentB.length) {
    throw new Error('adjustedRandIndex: assignmentA and assignmentB must have equal length');
  }
  const n = assignmentA.length;
  if (n < 2) return 1.0;

  // Build contingency table.
  const labelsA = new Map<string | number, number>();
  const labelsB = new Map<string | number, number>();
  for (let i = 0; i < n; i++) {
    labelsA.set(assignmentA[i]!, (labelsA.get(assignmentA[i]!) ?? 0) + 1);
    labelsB.set(assignmentB[i]!, (labelsB.get(assignmentB[i]!) ?? 0) + 1);
  }
  // Co-occurrence count.
  const coKey = (a: string | number, b: string | number) => `${a} ${b}`;
  const co = new Map<string, number>();
  for (let i = 0; i < n; i++) {
    const k = coKey(assignmentA[i]!, assignmentB[i]!);
    co.set(k, (co.get(k) ?? 0) + 1);
  }

  // Pairwise sums.
  const choose2 = (k: number) => (k * (k - 1)) / 2;
  let sumCo = 0;
  for (const v of co.values()) sumCo += choose2(v);
  let sumA = 0;
  for (const v of labelsA.values()) sumA += choose2(v);
  let sumB = 0;
  for (const v of labelsB.values()) sumB += choose2(v);
  const totalPairs = choose2(n);
  if (totalPairs === 0) return 1.0;

  const expectedIndex = (sumA * sumB) / totalPairs;
  const maxIndex = (sumA + sumB) / 2;
  const denominator = maxIndex - expectedIndex;
  if (denominator === 0) return 0;
  return (sumCo - expectedIndex) / denominator;
}

/**
 * Combined v0.34 result-shape compare. Returns an object with all
 * applicable metrics for the given op tool. The caller picks which
 * metric to report.
 */
export interface CodeWalkResultShape {
  result?: string;
  depth_groups?: Array<{ depth: number; nodes: GraphNode[] }>;
  truncation?: string;
}

export interface CodeWalkComparison {
  jaccard: number;
  depth_stability: number;
  truncation_match: number;
}

export function compareCodeWalk(
  a: CodeWalkResultShape,
  b: CodeWalkResultShape,
): CodeWalkComparison {
  const aNodes = (a.depth_groups ?? []).flatMap((g) => g.nodes);
  const bNodes = (b.depth_groups ?? []).flatMap((g) => g.nodes);
  return {
    jaccard: nodeSetJaccard(aNodes, bNodes),
    depth_stability: depthGroupStability(a.depth_groups ?? [], b.depth_groups ?? []),
    truncation_match: truncationMatch(a.truncation, b.truncation),
  };
}
