/**
 * YAML source-preserving patch helper.
 *
 * Problem: `yaml.stringify(obj)` completely re-serializes a document, destroying
 * the source formatting (block literal vs folded, inline vs block arrays, blank
 * lines, comments, key order). This changes `instruction: |` to `instruction: >`,
 * which actually alters runtime behavior because folded style collapses newlines.
 *
 * Solution: parseDocument(originalText) -> deep-diff doc.toJS() vs newBody ->
 * apply only differing paths via Document#setIn / Document#deleteIn. Untouched
 * regions preserve their original source exactly.
 *
 * Newly-added subtrees go through Document#createNode with the existing
 * lineWidth: 120 convention.
 */

import { parseDocument, stringify, type Document } from 'yaml';
import { logger } from '../logger.js';

const LINE_WIDTH = 120;

type Path = (string | number)[];

/**
 * Recursively walk `prev` and `next` (both plain JS values), collecting paths
 * where the two differ. For differing paths we emit either a set (with the new
 * value) or a delete.
 *
 * Rules:
 * - For objects (plain dicts) we compare by key. Keys removed in `next` become
 *   deletes; keys added in `next` become sets; common keys recurse.
 * - For arrays of equal length we recurse element-by-element by index. This
 *   lets us do minimal edits inside one movement without re-serializing the
 *   entire `movements:` sequence (which would flatten its inline arrays and
 *   block-literal instructions).
 * - For arrays of differing length we replace the whole array at that path.
 *   Element-wise alignment across inserts/deletes is ambiguous without a
 *   stable identity field, so we bail to wholesale replacement and accept
 *   the one-time formatting loss for the mutated sequence.
 * - For primitives (string/number/bool/null) we compare via strict equality.
 */
export type DiffOp =
  | { kind: 'set'; path: Path; value: unknown }
  | { kind: 'delete'; path: Path };

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

function deepEqual(a: unknown, b: unknown): boolean {
  if (a === b) return true;
  if (typeof a !== typeof b) return false;
  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
      if (!deepEqual(a[i], b[i])) return false;
    }
    return true;
  }
  if (isPlainObject(a) && isPlainObject(b)) {
    const keysA = Object.keys(a);
    const keysB = Object.keys(b);
    if (keysA.length !== keysB.length) return false;
    for (const k of keysA) {
      if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
      if (!deepEqual(a[k], b[k])) return false;
    }
    return true;
  }
  return false;
}

export function diff(prev: unknown, next: unknown, path: Path = []): DiffOp[] {
  if (deepEqual(prev, next)) return [];

  // If the shapes differ (object<->array<->scalar) replace wholesale.
  const prevIsObj = isPlainObject(prev);
  const nextIsObj = isPlainObject(next);
  const prevIsArr = Array.isArray(prev);
  const nextIsArr = Array.isArray(next);

  if (prevIsObj && nextIsObj) {
    const ops: DiffOp[] = [];
    const prevKeys = new Set(Object.keys(prev as Record<string, unknown>));
    const nextKeys = new Set(Object.keys(next as Record<string, unknown>));
    // deletions
    for (const k of prevKeys) {
      if (!nextKeys.has(k)) {
        ops.push({ kind: 'delete', path: [...path, k] });
      }
    }
    // additions + recursive diffs
    for (const k of nextKeys) {
      const nextVal = (next as Record<string, unknown>)[k];
      if (!prevKeys.has(k)) {
        ops.push({ kind: 'set', path: [...path, k], value: nextVal });
      } else {
        const prevVal = (prev as Record<string, unknown>)[k];
        ops.push(...diff(prevVal, nextVal, [...path, k]));
      }
    }
    return ops;
  }

  if (prevIsArr && nextIsArr) {
    const prevArr = prev as unknown[];
    const nextArr = next as unknown[];
    if (prevArr.length !== nextArr.length) {
      // Length change: bail to wholesale replacement. We can't reliably align
      // items across insertions/deletions without a stable identity field.
      return [{ kind: 'set', path, value: next }];
    }
    // Equal length: recurse element-wise so untouched items keep formatting.
    const ops: DiffOp[] = [];
    for (let i = 0; i < prevArr.length; i++) {
      ops.push(...diff(prevArr[i], nextArr[i], [...path, i]));
    }
    return ops;
  }

  // Shape change or primitive mismatch: replace.
  return [{ kind: 'set', path, value: next }];
}

/**
 * Apply a list of diff ops to a Document in place. New subtrees are wrapped via
 * doc.createNode so they follow our stringify options (lineWidth etc.).
 */
export function applyOps(doc: Document, ops: DiffOp[]): void {
  for (const op of ops) {
    if (op.kind === 'delete') {
      doc.deleteIn(op.path);
    } else {
      // createNode respects schema + options; we don't pass extra options
      // because per-field block style for untouched content comes from the
      // original Document and we only call createNode for NEW subtrees.
      const node = doc.createNode(op.value);
      doc.setIn(op.path, node);
    }
  }
}

/**
 * Re-serialize `body` onto the formatting of `originalText`, preserving block
 * styles / inline arrays / blank lines / comments for untouched regions.
 *
 * If the original text fails to parse cleanly (errors array non-empty) we fall
 * back to a plain stringify and log a warning.
 */
export function patchYaml(originalText: string, body: unknown): string {
  let doc: Document;
  try {
    doc = parseDocument(originalText);
  } catch (e) {
    logger.warn(`[yaml-patch] parseDocument threw, falling back to stringify err=${e}`);
    return stringify(body, { lineWidth: LINE_WIDTH });
  }
  if (doc.errors && doc.errors.length > 0) {
    logger.warn(
      `[yaml-patch] original document has parse errors, falling back to stringify count=${doc.errors.length}`,
    );
    return stringify(body, { lineWidth: LINE_WIDTH });
  }

  const prev = doc.toJS();
  const ops = diff(prev, body);
  applyOps(doc, ops);
  return doc.toString({ lineWidth: LINE_WIDTH });
}
