#!/usr/bin/env node
/**
 * Precompute Shadow Diff — PR-7 Active Memory Shadow Result Diff
 *
 * Compares primary recall results against shadow precompute results
 * for offline analysis. Pure function — no side effects, no I/O, no
 * writes to MEMORY.md, SQLite, FTS5, or cache.
 *
 * Identity Resolution (priority order):
 *   1. `id` field
 *   2. `memoryId` field
 *   3. `key` field
 *   4. `content` field
 *   5. Stable JSON.stringify (deterministic key ordering)
 *
 * The identity is used to determine overlap between primary and shadow
 * result sets. All comparisons are read-only.
 *
 * @module precompute-shadow-diff
 */

/**
 * Resolve the stable identity key for a result item.
 *
 * Priorities:
 *   1. `id` (string)
 *   2. `memoryId` (string)
 *   3. `key` (string)
 *   4. `content` (string) — truncated to first 200 chars to keep keys bounded
 *   5. Stable JSON.stringify as last resort
 *
 * @param {object} item - A result item (memory entry)
 * @returns {string} Stable identity key
 */
function resolveIdentity(item) {
  if (item === null || item === undefined) {
    return "null";
  }
  if (typeof item !== "object") {
    try {
      return JSON.stringify(item, Object.keys(item).sort());
    } catch {
      return String(item);
    }
  }

  // Priority 1: id
  if (typeof item.id === "string" && item.id.length > 0) {
    return `id:${item.id}`;
  }
  if (item.id !== undefined && item.id !== null) {
    return `id:${String(item.id)}`;
  }

  // Priority 2: memoryId
  if (typeof item.memoryId === "string" && item.memoryId.length > 0) {
    return `memoryId:${item.memoryId}`;
  }
  if (item.memoryId !== undefined && item.memoryId !== null) {
    return `memoryId:${String(item.memoryId)}`;
  }

  // Priority 3: key
  if (typeof item.key === "string" && item.key.length > 0) {
    return `key:${item.key}`;
  }
  if (item.key !== undefined && item.key !== null) {
    return `key:${String(item.key)}`;
  }

  // Priority 4: content (truncated for bounded keys)
  if (typeof item.content === "string" && item.content.length > 0) {
    const truncated = item.content.length > 200
      ? item.content.slice(0, 200)
      : item.content;
    return `content:${truncated}`;
  }

  // Priority 5: stable JSON stringify
  try {
    const keys = Object.keys(item).sort();
    const stable = {};
    for (const k of keys) {
      stable[k] = item[k];
    }
    return `json:${JSON.stringify(stable)}`;
  } catch {
    return `json:${String(item)}`;
  }
}

/**
 * Normalize a result set into a deduplicated array of items.
 *
 * Handles:
 *   - null / undefined input → empty array
 *   - Arrays directly (common case)
 *   - Objects with a `memories` key (gateway/precompute contract shape)
 *   - Objects with a `results` key (alternative contract shape)
 *   - Objects with an `items` key (alternative contract shape)
 *   - Any other object → wrapped in array
 *   - Non-array non-object → empty array
 *   - Duplicate items (by identity) → deduplicated, keeping first occurrence
 *   - Malformed items → filtered out (only objects and primitives pass)
 *
 * @param {*} result - The raw result from primary or shadow recall
 * @returns {Array<object>} Deduplicated array of result items
 */
function normalizeResultSet(result) {
  if (result === null || result === undefined) {
    return [];
  }

  let rawItems;

  if (Array.isArray(result)) {
    // Direct array of items
    rawItems = result;
  } else if (typeof result === "object") {
    // Try common result shapes
    if (Array.isArray(result.memories)) {
      rawItems = result.memories;
    } else if (Array.isArray(result.results)) {
      rawItems = result.results;
    } else if (Array.isArray(result.items)) {
      rawItems = result.items;
    } else {
      // Object but no known array key — wrap it
      rawItems = [result];
    }
  } else {
    // String, number, or other primitive — no items
    return [];
  }

  // Filter valid items (must be objects or truthy primitives)
  const filtered = rawItems.filter(item => {
    if (item === null || item === undefined) return false;
    return true;
  });

  // Deduplicate by identity, keeping first occurrence
  const seen = new Set();
  const deduped = [];
  for (const item of filtered) {
    const key = resolveIdentity(item);
    if (!seen.has(key)) {
      seen.add(key);
      deduped.push(item);
    }
  }

  return deduped;
}

/**
 * Compute the overlap ratio between two sets given by identity keys.
 *
 * @param {Set<string>} primaryKeys - Identity keys from primary result
 * @param {Set<string>} shadowKeys - Identity keys from shadow result
 * @returns {number} Ratio 0-1, or 0 if both sets empty
 */
function computeOverlapRatio(primaryKeys, shadowKeys) {
  const total = primaryKeys.size + shadowKeys.size;
  if (total === 0) return 0;

  let overlap = 0;
  for (const key of primaryKeys) {
    if (shadowKeys.has(key)) overlap++;
  }

  // Jaccard-like: overlap / (union) — but we want symmetric measure
  const union = primaryKeys.size + shadowKeys.size - overlap;
  if (union === 0) return 0;
  return overlap / union;
}

/**
 * Compute the top-K overlap count between two ordered result sets.
 *
 * Compares the first `k` items of each set by identity. Items beyond
 * the shorter set's length are counted as non-overlapping.
 *
 * @param {Array<object>} primaryItems - Deduplicated primary items
 * @param {Array<object>} shadowItems - Deduplicated shadow items
 * @param {number} k - How many top items to compare (default: 5)
 * @returns {number} Count of overlapping items in top K positions
 */
function computeTopKOverlap(primaryItems, shadowItems, k = 5) {
  const primaryKeys = primaryItems.slice(0, k).map(resolveIdentity);
  const shadowKeys = shadowItems.slice(0, k).map(resolveIdentity);

  let overlap = 0;
  for (const pk of primaryKeys) {
    if (shadowKeys.includes(pk)) overlap++;
  }
  return overlap;
}

/**
 * Check if the top result is the same in both sets.
 *
 * @param {Array<object>} primaryItems - Deduplicated primary items
 * @param {Array<object>} shadowItems - Deduplicated shadow items
 * @returns {boolean} true if both have at least one item and top items match
 */
function isSameTopResult(primaryItems, shadowItems) {
  if (primaryItems.length === 0 && shadowItems.length === 0) {
    // Both empty → conceptually "same" (no top result to differ on)
    return true;
  }
  if (primaryItems.length === 0 || shadowItems.length === 0) {
    return false;
  }

  return resolveIdentity(primaryItems[0]) === resolveIdentity(shadowItems[0]);
}

/**
 * Compute the full structural diff between primary and shadow recall results.
 *
 * This is a PURE FUNCTION. It does NOT:
 *   - Read or write any files
 *   - Read or write environment variables
 *   - Read or write SQLite / FTS5 databases
 *   - Modify MEMORY.md or any memory files
 *   - Touch the precompute cache
 *   - Have any side effects whatsoever
 *
 * @param {object} options
 * @param {*} options.primaryResult - The primary (blocking) recall result
 * @param {*} options.shadowResult - The shadow (precompute) result
 * @param {object} [options.hookContext] - Context object (sessionId, etc.)
 * @param {number} [options.topK] - Top-K value for overlap comparison (default: 5)
 * @param {number} [options.latencyMs] - Shadow latency in milliseconds
 * @returns {object} Structured diff:
 *   - status: "ok" | "shadow_null" | "primary_null" | "shadow_empty" | "both_empty" | "malformed"
 *   - primaryCount: number
 *   - shadowCount: number
 *   - overlapCount: number
 *   - primaryOnlyCount: number
 *   - shadowOnlyCount: number
 *   - overlapRatio: number (0-1)
 *   - primaryOnly: string[] (identity keys only in primary)
 *   - shadowOnly: string[] (identity keys only in shadow)
 *   - sameTopResult: boolean
 *   - topKOverlap: number
 *   - latencyMs: number | null
 */
function computeShadowDiff({ primaryResult, shadowResult, hookContext, topK = 5, latencyMs = null }) {
  // Normalize both result sets
  const primaryItems = normalizeResultSet(primaryResult);
  const shadowItems = normalizeResultSet(shadowResult);

  // Determine status
  let status = "ok";
  if (shadowResult === null || shadowResult === undefined) {
    status = "shadow_null";
  } else if (primaryResult === null || primaryResult === undefined) {
    status = "primary_null";
  } else if (primaryItems.length === 0 && shadowItems.length === 0) {
    status = "both_empty";
  } else if (shadowItems.length === 0) {
    status = "shadow_empty";
  } else if (primaryItems.length === 0) {
    status = "primary_empty";
  }

  // Compute identity sets
  const primaryKeys = new Set(primaryItems.map(resolveIdentity));
  const shadowKeys = new Set(shadowItems.map(resolveIdentity));

  // Overlap
  let overlapCount = 0;
  for (const key of primaryKeys) {
    if (shadowKeys.has(key)) overlapCount++;
  }

  // Primary-only
  const primaryOnlyKeys = [];
  for (const key of primaryKeys) {
    if (!shadowKeys.has(key)) primaryOnlyKeys.push(key);
  }

  // Shadow-only
  const shadowOnlyKeys = [];
  for (const key of shadowKeys) {
    if (!primaryKeys.has(key)) shadowOnlyKeys.push(key);
  }

  // Ratios and checks
  const overlapRatio = computeOverlapRatio(primaryKeys, shadowKeys);
  const topKOverlap = computeTopKOverlap(primaryItems, shadowItems, topK);
  const sameTop = isSameTopResult(primaryItems, shadowItems);

  return {
    status,
    primaryCount: primaryKeys.size,
    shadowCount: shadowKeys.size,
    overlapCount,
    primaryOnlyCount: primaryOnlyKeys.length,
    shadowOnlyCount: shadowOnlyKeys.length,
    overlapRatio: Math.round(overlapRatio * 10000) / 10000, // 4 decimal places
    primaryOnly: primaryOnlyKeys,
    shadowOnly: shadowOnlyKeys,
    sameTopResult: sameTop,
    topKOverlap,
    latencyMs: latencyMs !== null && latencyMs !== undefined ? latencyMs : null,
  };
}

export {
  computeShadowDiff,
  resolveIdentity,
  normalizeResultSet,
  computeOverlapRatio,
  computeTopKOverlap,
  isSameTopResult,
};
