export type ReplaceOp<T> = { $replace: T };
export type DeleteOp = { $delete: true };

// Treat these as atomic values (not recursed into)
type Builtin = Date | RegExp | ((...args: unknown[]) => unknown);

type Primitive = string | number | boolean | bigint | symbol | null | undefined;

/**
 * Override<T> is a DeepPartial-like type with special operators:
 * - { $replace: T } to replace an entire subtree/array
 * - { $delete: true } to delete an object key
 *
 * Arrays behavior:
 * - Arrays of objects: merge-by-index by default (unless `arrayStrategy: 'replace'` or `$replace` is used)
 * - Arrays of primitives (string/number/boolean/...): replaced by default when a patch array is provided
 */
export type Override<T> =
  // atomic
  T extends Primitive | Builtin
    ? T | ReplaceOp<T> | DeleteOp
    : // arrays
      T extends (infer U)[]
      ? Array<Override<U> | undefined> | ReplaceOp<T>
      : // maps/sets – keep simple: only allow full replace
        T extends Map<unknown, unknown> | Set<unknown>
        ? ReplaceOp<T>
        : // objects
          T extends object
          ? { [K in keyof T]?: Override<T[K]> | DeleteOp | null } | ReplaceOp<T>
          : T;

export type MergeOptions = {
  /**
   * How to treat arrays when patch provides an array (not $replace):
   * - 'replace': always use patch array as whole value
   * - 'mergeByIndex': merge each index recursively (default for arrays of objects)
   * Note: Arrays of primitives are replaced by default regardless of this option.
   */
  arrayStrategy?: 'replace' | 'mergeByIndex';
  /**
   * If true, a `null` patch value deletes the key from an object (RFC 7396-ish)
   */
  nullDeletes?: boolean;
};

// Prevents T from being inferred from the 'patch' parameter; it must be inferred from 'base'.
// See: https://github.com/microsoft/TypeScript/issues/14829#issuecomment-504042546
type NoInfer<T> = [T][T extends unknown ? 0 : never];

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

function isReplace(x: unknown): x is ReplaceOp<unknown> {
  return !!x && typeof x === 'object' && '$replace' in (x as Record<string, unknown>);
}
function isDelete(x: unknown): x is DeleteOp {
  return !!x && typeof x === 'object' && (x as Record<string, unknown>).$delete === true;
}

function isPrimitiveValue(x: unknown): boolean {
  const t = typeof x;
  return (
    x === null ||
    t === 'string' ||
    t === 'number' ||
    t === 'boolean' ||
    t === 'bigint' ||
    t === 'symbol' ||
    t === 'undefined'
  );
}

function isArrayOfPrimitives(arr: unknown[]): boolean {
  if (arr.length === 0) return false; // ambiguous, default to merge-by-index on empty
  // If every defined element is primitive, treat as primitives
  for (let i = 0; i < arr.length; i += 1) {
    const v = arr[i];
    if (v !== undefined && !isPrimitiveValue(v)) return false;
  }
  return true;
}

function mergeAny(baseAny: unknown, patchAny: unknown, opts: MergeOptions): unknown {
  if (isReplace(patchAny)) return patchAny.$replace as unknown;
  if (isDelete(patchAny)) return undefined; // caller deletes the key

  if (Array.isArray(baseAny)) {
    if (Array.isArray(patchAny)) {
      const baseArr = baseAny as unknown[];
      const patchArr = patchAny as unknown[];
      const shouldReplace =
        opts.arrayStrategy === 'replace' ||
        isArrayOfPrimitives(baseArr) ||
        isArrayOfPrimitives(patchArr);
      if (shouldReplace) return patchAny as unknown;
      const out = baseArr.slice();
      const max = Math.max(out.length, patchArr.length);
      for (let i = 0; i < max; i += 1) {
        const pVal = patchArr[i];
        if (pVal !== undefined) {
          out[i] = mergeAny(out[i], pVal, opts);
        }
      }
      return out as unknown;
    }
    return baseAny as unknown;
  }

  if (isPlainObject(baseAny) && isPlainObject(patchAny)) {
    const baseObj = baseAny as Record<string, unknown>;
    const patchObj = patchAny as Record<string, unknown>;
    const out: Record<string, unknown> = { ...baseObj };
    for (const key of Object.keys(patchObj)) {
      const pVal = patchObj[key];
      if (pVal === undefined) {
        // leave as-is
      } else if (opts.nullDeletes && pVal === null) {
        delete out[key];
      } else if (isReplace(pVal)) {
        out[key] = pVal.$replace;
      } else if (isDelete(pVal)) {
        delete out[key];
      } else {
        out[key] = mergeAny(baseObj?.[key], pVal, opts);
      }
    }
    return out as unknown;
  }

  return (patchAny as unknown) ?? baseAny;
}

export function applyOverrides<T>(
  base: T,
  patch: Override<NoInfer<T>>,
  opts: MergeOptions = {},
): T {
  const { arrayStrategy = 'mergeByIndex', nullDeletes = false } = opts;

  // Handle whole-node control operators on patch itself
  if (isReplace(patch)) return patch.$replace as T;
  if (isDelete(patch)) return undefined as unknown as T;

  // Arrays
  if (Array.isArray(base)) {
    if (isReplace(patch)) return patch.$replace as T;
    if (Array.isArray(patch)) {
      if (arrayStrategy === 'replace') return patch as unknown as T;
      // merge by index
      const out = (base as unknown[]).slice();
      const max = Math.max(out.length, patch.length);
      for (let i = 0; i < max; i += 1) {
        const pVal = (patch as unknown[])[i];
        if (pVal !== undefined) {
          out[i] = mergeAny(out[i], pVal, opts);
        }
      }
      return out as unknown as T;
    }
    // invalid patch type for an array → ignore and return base
    return base as T;
  }

  // Plain objects
  if (isPlainObject(base) && isPlainObject(patch)) {
    const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
    const patchObj = patch as Record<string, unknown>;
    for (const key of Object.keys(patchObj)) {
      const pVal = patchObj[key];
      if (pVal === undefined) {
        // leave existing value
      } else if (nullDeletes && pVal === null) {
        delete out[key];
      } else if (isReplace(pVal)) {
        out[key] = pVal.$replace;
      } else if (isDelete(pVal)) {
        delete out[key];
      } else {
        const bVal = (base as Record<string, unknown>)[key];
        out[key] = mergeAny(bVal, pVal, opts);
      }
    }
    return out as unknown as T;
  }

  // For primitives or when patch is primitive, patch wins if defined
  return ((patch as unknown) ?? base) as T;
}
