/**
 * Path-aligned JSON-tree diff helpers for the "compare two log requests" feature.
 *
 * Given two captured log Request payloads, we:
 *  1. Normalize each payload (deep-clone, sort object keys, keep array order)
 *     so that non-semantic differences (key order, string whitespace) do not
 *     generate false positives.
 *  2. Walk the two trees in lockstep, emitting a list of path-anchored
 *     `DiffOp`s. Equal subtrees collapse into a single op at the subtree
 *     root, so the renderer can choose to expand or hide them.
 *  3. Emit ops in path-sorted order (depth-first, sibling order = object-key
 *     ascending then array-index ascending) so the renderer can lay them
 *     out linearly.
 *
 * No runtime dependencies. Mirrors the `cacheTrend.ts` pure-helper pattern.
 */

export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };

export type JsonNode =
  | { kind: "object"; value: Record<string, JsonNode> }
  | { kind: "array"; value: JsonNode[] }
  | { kind: "primitive"; value: JsonPrimitive };

export type DiffOp =
  | { kind: "equal"; path: string; value: JsonNode }
  | { kind: "added"; path: string; value: JsonNode }
  | { kind: "removed"; path: string; value: JsonNode }
  | {
      kind: "changed";
      path: string;
      left: JsonNode;
      right: JsonNode;
    };

/** A single segment of a JSON path: an object key or an array index. */
export type PathSegment = string | number;

const ROOT_PATH = "";

/** Render a path segment list as a human-readable string.
 *
 *  - Root (empty list) → `""` (no label; the renderer hides the gutter)
 *  - Object key        → `.foo`
 *  - Array index       → `[3]`
 *  - Top-level object key  → `messages` (no leading dot)
 *
 *  Examples:
 *    []                                → ""
 *    ["messages"]                      → "messages"
 *    ["messages", 3]                   → "messages[3]"
 *    ["messages", 3, "content"]        → "messages[3].content"
 *    ["messages", 3, "content", 0]     → "messages[3].content[0]"
 */
export function formatPath(segments: PathSegment[]): string {
  if (segments.length === 0) return ROOT_PATH;
  let out = "";
  for (let i = 0; i < segments.length; i++) {
    const seg = segments[i];
    if (seg === undefined) continue;
    if (typeof seg === "number") {
      out += `[${seg}]`;
    } else if (i === 0) {
      out += seg;
    } else {
      out += `.${seg}`;
    }
  }
  return out;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
 * Normalize a captured Request body for comparison.
 *
 * Captured Request bodies are sometimes already-parsed objects and sometimes
 * JSON-encoded strings (depending on which capture path produced the log).
 * We:
 *   1. Try `JSON.parse` if the input is a string.
 *   2. Deep-walk the value, building a fresh `JsonNode` tree.
 *   3. Sort object keys lexicographically (key order is not semantically
 *      meaningful for most LLM SDKs but varies between SDK versions, so we
 *      canonicalize it).
 *   4. Keep array order (semantically meaningful: `messages[]` order is
 *      conversation order; reordering it would silently destroy the diff).
 *   5. Leave primitives alone (no whitespace trim — those can be meaningful
 *      in user-message text).
 *
 * Idempotent at the data level: re-normalizing the same shape (in any key
 * order) yields the same tree.
 */
export function normalizeRequest(raw: unknown): JsonNode {
  if (typeof raw === "string") {
    try {
      return toNode(JSON.parse(raw));
    } catch {
      // Unparseable string: wrap as a primitive string node so the diff can
      // still operate on it (changed vs. another string, or added/removed
      // vs. an object).
      return { kind: "primitive", value: raw };
    }
  }
  return toNode(raw);
}

function toNode(value: unknown): JsonNode {
  if (value === null) return { kind: "primitive", value: null };
  if (typeof value === "string") return { kind: "primitive", value };
  if (typeof value === "number") return { kind: "primitive", value };
  if (typeof value === "boolean") return { kind: "primitive", value };
  if (Array.isArray(value)) {
    return { kind: "array", value: value.map((v) => toNode(v)) };
  }
  if (isPlainObject(value)) {
    const out: Record<string, JsonNode> = {};
    for (const k of Object.keys(value).sort()) {
      out[k] = toNode(value[k]);
    }
    return { kind: "object", value: out };
  }
  // Functions, symbols, bigints, undefined — treat as null in the diff model.
  return { kind: "primitive", value: null };
}

/**
 * Compute the path-aligned diff of two normalized trees.
 *
 * Emits `DiffOp[]` in path-sorted order. For each path:
 *   - if both trees have the same value, emit a single `equal` op at the
 *     deepest common ancestor (so equal subtrees collapse into one op);
 *   - if only the right has it, emit `added`;
 *   - if only the left has it, emit `removed`;
 *   - if both have it but it differs, emit `changed` (and recurse into
 *     objects/arrays so the user sees *which* field changed).
 *
 * Pure: does not mutate `left` or `right`.
 */
export function diffTrees(left: JsonNode, right: JsonNode): DiffOp[] {
  const ops: DiffOp[] = [];
  walk([], left, right, ops);
  return ops;
}

function walk(segments: PathSegment[], left: JsonNode, right: JsonNode, out: DiffOp[]): void {
  const path = formatPath(segments);

  if (nodeEqual(left, right)) {
    out.push({ kind: "equal", path, value: left });
    return;
  }

  // Type mismatch or primitive change.
  if (left.kind !== right.kind) {
    out.push({ kind: "changed", path, left, right });
    return;
  }

  if (left.kind === "primitive" && right.kind === "primitive") {
    out.push({ kind: "changed", path, left, right });
    return;
  }

  if (left.kind === "object" && right.kind === "object") {
    const leftKeys = Object.keys(left.value);
    const rightKeys = Object.keys(right.value);
    const rightKeySet = new Set(rightKeys);

    for (const k of leftKeys) {
      const lChild = left.value[k];
      if (lChild === undefined) continue;
      if (!rightKeySet.has(k)) {
        out.push({
          kind: "removed",
          path: formatPath([...segments, k]),
          value: lChild,
        });
      } else {
        const rChild = right.value[k];
        if (rChild === undefined) continue;
        walk([...segments, k], lChild, rChild, out);
      }
    }
    for (const k of rightKeys) {
      if (leftKeys.includes(k)) continue;
      const rChild = right.value[k];
      if (rChild === undefined) continue;
      out.push({
        kind: "added",
        path: formatPath([...segments, k]),
        value: rChild,
      });
    }
    return;
  }

  if (left.kind === "array" && right.kind === "array") {
    const minLen = Math.min(left.value.length, right.value.length);
    for (let i = 0; i < minLen; i++) {
      const lChild = left.value[i];
      const rChild = right.value[i];
      if (lChild === undefined || rChild === undefined) continue;
      walk([...segments, i], lChild, rChild, out);
    }
    for (let i = minLen; i < right.value.length; i++) {
      const rChild = right.value[i];
      if (rChild === undefined) continue;
      out.push({
        kind: "added",
        path: formatPath([...segments, i]),
        value: rChild,
      });
    }
    for (let i = minLen; i < left.value.length; i++) {
      const lChild = left.value[i];
      if (lChild === undefined) continue;
      out.push({
        kind: "removed",
        path: formatPath([...segments, i]),
        value: lChild,
      });
    }
  }
}

function nodeEqual(a: JsonNode, b: JsonNode): boolean {
  if (a.kind !== b.kind) return false;
  if (a.kind === "primitive" && b.kind === "primitive") {
    return a.value === b.value;
  }
  if (a.kind === "array" && b.kind === "array") {
    if (a.value.length !== b.value.length) return false;
    for (let i = 0; i < a.value.length; i++) {
      const ai = a.value[i];
      const bi = b.value[i];
      if (ai === undefined || bi === undefined) return false;
      if (!nodeEqual(ai, bi)) return false;
    }
    return true;
  }
  if (a.kind === "object" && b.kind === "object") {
    const aKeys = Object.keys(a.value);
    const bKeys = Object.keys(b.value);
    if (aKeys.length !== bKeys.length) return false;
    for (const k of aKeys) {
      const av = a.value[k];
      const bv = b.value[k];
      if (av === undefined || bv === undefined) return false;
      if (!nodeEqual(av, bv)) return false;
    }
    return true;
  }
  return false;
}

/** Render a JsonNode to a compact human-readable preview. Used by the
 *  diff view's gutter or change rows where we want a one-line summary
 *  without pretty-printing the whole subtree. */
export function previewNode(node: JsonNode, maxLen = 80): string {
  let s: string;
  switch (node.kind) {
    case "primitive":
      s = node.value === null ? "null" : JSON.stringify(node.value);
      break;
    case "array":
      s = `[… ${node.value.length} items]`;
      break;
    case "object":
      s = `{… ${Object.keys(node.value).length} keys}`;
      break;
  }
  if (s.length > maxLen) s = `${s.slice(0, maxLen - 1)}…`;
  return s;
}
