import { diffJson, diffLines, type Change } from "diff";

/**
 * A single line in a unified diff view.
 *
 * - `kind: "context"` — line present in both sides
 * - `kind: "removed"` — line only in the left (raw) side
 * - `kind: "added"` — line only in the right (processed) side
 *
 * `oldLineNumber` / `newLineNumber` are 1-based and `null` for the side the
 * line is not on. `text` always includes the trailing newline-free content
 * for that line.
 */
export type DiffLine = {
  kind: "context" | "removed" | "added";
  text: string;
  oldLineNumber: number | null;
  newLineNumber: number | null;
};

/** Result of computing a diff: the normalized lines plus a quick "no change" flag. */
export type DiffResult = {
  lines: DiffLine[];
  /** True when the two inputs are byte-equal (or semantically equal for JSON). */
  isEmpty: boolean;
};

/**
 * Normalize an HTTP-header record into a deterministic `[lowercaseKey, value]`
 * tuple. Lowercasing the key matches how `buildProxyHeaders` stores raw
 * headers in `handler.ts`, and sorting by key gives stable diffs across
 * different client header orders.
 */
function normalizeHeaders(headers: Record<string, string> | undefined): Array<[string, string]> {
  if (headers === undefined) return [];
  return Object.entries(headers)
    .map((entry): [string, string] => [entry[0].toLowerCase(), entry[1]])
    .sort(([a], [b]) => a.localeCompare(b));
}

/**
 * Render a header entry as a single line in the form `key: value`. Mirrors
 * the layout used by the existing Headers / Raw Headers tabs in
 * `LogEntry.tsx` so the diff view looks like the same data, just with
 * add/remove highlights.
 */
function headerLine([key, value]: [string, string]): string {
  return `${key}: ${value}`;
}

/**
 * Convert a list of `diff` library `Change` objects into `DiffLine` records
 * with stable 1-based line numbers on each side. Each change's value is
 * split on `\n` so we can produce per-line rows (the `diff` library groups
 * runs of consecutive equal/added/removed lines into one Change).
 */
function changesToLines(changes: Change[]): DiffLine[] {
  const lines: DiffLine[] = [];
  let oldNum = 0;
  let newNum = 0;
  for (const change of changes) {
    // Trailing newlines are part of the change's value; dropping the empty
    // string after the final `\n` keeps the line count correct.
    const parts = change.value.split("\n");
    if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
    for (const text of parts) {
      if (change.added) {
        newNum += 1;
        lines.push({ kind: "added", text, oldLineNumber: null, newLineNumber: newNum });
      } else if (change.removed) {
        oldNum += 1;
        lines.push({ kind: "removed", text, oldLineNumber: oldNum, newLineNumber: null });
      } else {
        oldNum += 1;
        newNum += 1;
        lines.push({
          kind: "context",
          text,
          oldLineNumber: oldNum,
          newLineNumber: newNum,
        });
      }
    }
  }
  return lines;
}

/**
 * Compute a diff between two HTTP-header records. Headers are normalized
 * (lower-cased keys, sorted) before comparison so that key reordering or
 * case differences don't show as spurious changes.
 *
 * Returns `{ lines, isEmpty: true }` when the two records contain the same
 * key/value pairs, which the UI uses to show a "No transformation applied"
 * placeholder instead of an empty diff.
 */
export function computeHeadersDiff(
  rawHeaders: Record<string, string> | undefined,
  processedHeaders: Record<string, string> | undefined,
): DiffResult {
  const left = normalizeHeaders(rawHeaders).map(headerLine).join("\n");
  const right = normalizeHeaders(processedHeaders).map(headerLine).join("\n");

  if (left === right) {
    return { lines: [], isEmpty: true };
  }
  const changes = diffLines(left, right);
  return { lines: changesToLines(changes), isEmpty: false };
}

/**
 * Try to parse a string as JSON. Returns the parsed value on success, or
 * `null` on failure. We use this to decide between semantic (`diffJson`) and
 * line-level (`diffLines`) diffs for request bodies. The return type is a
 * union of `null` and the input types `diffJson` accepts, so the call site
 * can pass the result directly without re-narrowing.
 */
function tryParseJson(text: string): null | string | object {
  try {
    const parsed: unknown = JSON.parse(text);
    if (parsed === null) return parsed;
    if (typeof parsed === "string" || typeof parsed === "object") return parsed;
    // Numbers, booleans, etc. — also valid JSON, but the diffJson overloads
    // only accept string | object, so we surface these as line-diff by
    // returning null.
    return null;
  } catch {
    return null;
  }
}

/**
 * Compute a diff between two request bodies.
 *
 * - If both sides parse as JSON, use `diffJson` for a semantic key-level
 *   comparison. Reordering fields, or adding/removing a key inside an
 *   object, produces cleaner output than a raw text diff.
 * - If either side is not valid JSON, fall back to `diffLines` of the raw
 *   strings. This keeps the diff working for non-JSON bodies (rare in this
 *   proxy, but possible) and for partial / truncated bodies.
 *
 * The two inputs are compared as-is. Whitespace and key order in JSON are
 * normalized by the parser, so two semantically-equal bodies will report
 * `isEmpty: true` even if their textual forms differ.
 */
export function computeRequestDiff(
  rawBody: string | null,
  processedBody: string | null,
): DiffResult {
  const left = rawBody ?? "";
  const right = processedBody ?? "";

  if (left === right) {
    return { lines: [], isEmpty: true };
  }

  const leftJson = tryParseJson(left);
  const rightJson = tryParseJson(right);
  if (leftJson !== null && rightJson !== null) {
    // diffJson runs canonicalize() on both sides, so key order / whitespace
    // differences are handled before the line diff is built. Two JSON values
    // that are deep-equal produce an empty change set. The overload set
    // includes some signatures that return `undefined` (abortable modes);
    // since we pass no options, the result is always defined in practice,
    // but we guard with a fallback to the line diff for type-safety.
    const changes = diffJson(leftJson, rightJson) ?? [];
    if (changes.length === 1) {
      const first = changes[0];
      if (first !== undefined && !first.added && !first.removed) {
        return { lines: [], isEmpty: true };
      }
    }
    return { lines: changesToLines(changes), isEmpty: false };
  }

  const changes = diffLines(left, right);
  return { lines: changesToLines(changes), isEmpty: false };
}
