import { pascalToKebab } from "./strings";

export function _jsonp(obj: any, space = 2): string {
   return JSON.stringify(obj, null, space);
}

export function isPlainObject(value: unknown): value is Record<string, unknown> {
   return Object.prototype.toString.call(value) === "[object Object]";
}

export function isObject(value: unknown): value is Record<string, unknown> {
   return value !== null && typeof value === "object";
}

export function omitKeys<T extends object, K extends keyof T>(
   obj: T,
   keys_: readonly K[] | K[] | string[],
): Omit<T, Extract<K, keyof T>> {
   const keys = new Set(keys_ as readonly K[]);
   const result = {} as Omit<T, Extract<K, keyof T>>;
   for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
      if (!keys.has(key as K)) {
         (result as any)[key] = value;
      }
   }
   return result;
}

export function pickKeys<T extends object, K extends keyof T>(
   obj: T,
   keys_: readonly K[],
): Pick<T, Extract<K, keyof T>> {
   const keys = new Set(keys_);
   const result = {} as Pick<T, Extract<K, keyof T>>;
   for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
      if (keys.has(key as K)) {
         (result as any)[key] = value;
      }
   }
   return result;
}

export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
   return Object.entries(obj).reduce((acc, [key, value]) => {
      try {
         // @ts-ignore
         acc[key] = JSON.parse(value);
      } catch (error) {
         // @ts-ignore
         acc[key] = value;
      }
      return acc;
   }, {} as T);
}

export function keepChanged<T extends object>(origin: T, updated: T): Partial<T> {
   return Object.keys(updated).reduce(
      (acc, key) => {
         if (updated[key] !== origin[key]) {
            acc[key] = updated[key];
         }
         return acc;
      },
      {} as Partial<T>,
   );
}

export function objectKeysPascalToKebab(obj: any, ignoreKeys: string[] = []): any {
   if (obj === null || typeof obj !== "object") {
      return obj;
   }

   if (Array.isArray(obj)) {
      return obj.map((item) => objectKeysPascalToKebab(item, ignoreKeys));
   }

   return Object.keys(obj).reduce(
      (acc, key) => {
         const kebabKey = ignoreKeys.includes(key) ? key : pascalToKebab(key);
         acc[kebabKey] = objectKeysPascalToKebab(obj[key], ignoreKeys);
         return acc;
      },
      {} as Record<string, any>,
   );
}

export function filterKeys<Object extends { [key: string]: any }>(
   obj: Object,
   keysToFilter: string[],
): Object {
   const result = {} as Object;

   for (const key in obj) {
      const shouldFilter = keysToFilter.some((filterKey) => key.includes(filterKey));
      if (!shouldFilter) {
         if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
            result[key] = filterKeys(obj[key], keysToFilter);
         } else {
            result[key] = obj[key];
         }
      }
   }

   return result;
}

export function transformObject<T extends Record<string, any>, U>(
   object: T,
   transform: (value: T[keyof T], key: keyof T) => U | undefined,
): { [K in keyof T]: U } {
   const result = {} as { [K in keyof T]: U };
   for (const [key, value] of Object.entries(object) as [keyof T, T[keyof T]][]) {
      const t = transform(value, key);
      if (typeof t !== "undefined") {
         result[key] = t;
      }
   }
   return result;
}
export const objectTransform = transformObject;

export function objectEach<T extends Record<string, any>, U>(
   object: T,
   each: (value: T[keyof T], key: keyof T) => U,
): void {
   Object.entries(object).forEach(
      ([key, value]) => {
         each(value, key);
      },
      {} as { [K in keyof T]: U },
   );
}

/**
 * Deep merge two objects.
 * @param target
 * @param ...sources
 */
export function mergeDeep(target, ...sources) {
   if (!sources.length) return target;
   const source = sources.shift();

   if (isObject(target) && isObject(source)) {
      for (const key in source) {
         if (isObject(source[key])) {
            if (!target[key]) Object.assign(target, { [key]: {} });
            mergeDeep(target[key], source[key]);
         } else {
            Object.assign(target, { [key]: source[key] });
         }
      }
   }

   return mergeDeep(target, ...sources);
}

export function getFullPathKeys(obj: any, parentPath: string = ""): string[] {
   let keys: string[] = [];

   for (const key in obj) {
      const fullPath = parentPath ? `${parentPath}.${key}` : key;
      keys.push(fullPath);

      if (typeof obj[key] === "object" && obj[key] !== null) {
         keys = keys.concat(getFullPathKeys(obj[key], fullPath));
      }
   }

   return keys;
}

export function flattenObject(obj: any, parentKey = "", result: any = {}): any {
   for (const key in obj) {
      if (key in obj) {
         const newKey = parentKey ? `${parentKey}.${key}` : key;
         if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
            flattenObject(obj[key], newKey, result);
         } else if (Array.isArray(obj[key])) {
            obj[key].forEach((item, index) => {
               const arrayKey = `${newKey}.${index}`;
               if (typeof item === "object" && item !== null) {
                  flattenObject(item, arrayKey, result);
               } else {
                  result[arrayKey] = item;
               }
            });
         } else {
            result[newKey] = obj[key];
         }
      }
   }
   return result;
}

export function objectDepth(object: object): number {
   let level = 1;
   for (const key in object) {
      if (typeof object[key] === "object") {
         const depth = objectDepth(object[key]) + 1;
         level = Math.max(depth, level);
      }
   }
   return level;
}

export function limitObjectDepth<T>(obj: T, maxDepth: number): T {
   function _limit(current: any, depth: number): any {
      if (isPlainObject(current)) {
         if (depth > maxDepth) {
            return undefined;
         }
         const result: any = {};
         for (const key in current) {
            if (Object.prototype.hasOwnProperty.call(current, key)) {
               result[key] = _limit(current[key], depth + 1);
            }
         }
         return result;
      }
      if (Array.isArray(current)) {
         // Arrays themselves are not limited, but their object elements are
         return current.map((item) => _limit(item, depth));
      }
      // Primitives are always returned, regardless of depth
      return current;
   }
   return _limit(obj, 1);
}

export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
   if (!obj) return obj;
   return Object.entries(obj).reduce((acc, [key, value]) => {
      if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) {
         const nested = value.map(objectCleanEmpty);
         if (nested.length > 0) {
            acc[key] = nested;
         }
      } else if (value && typeof value === "object" && !Array.isArray(value)) {
         const nested = objectCleanEmpty(value);
         if (Object.keys(nested).length > 0) {
            acc[key] = nested;
         }
      } else if (value !== "" && value !== null && value !== undefined) {
         acc[key] = value;
      }
      return acc;
   }, {} as any);
}

/**
 * Lodash's merge implementation caused issues in Next.js environments
 * From: https://thescottyjam.github.io/snap.js/#!/nolodash/merge
 * NOTE: This mutates `object`. It also may mutate anything that gets attached to `object` during the merge.
 * @param object
 * @param sources
 */
export function mergeObject(object, ...sources) {
   for (const source of sources) {
      for (const [key, value] of Object.entries(source)) {
         if (value === undefined) {
            continue;
         }

         // These checks are a week attempt at mimicking the various edge-case behaviors
         // that Lodash's `_.merge()` exhibits. Feel free to simplify and
         // remove checks that you don't need.
         if (!isPlainObject(value) && !Array.isArray(value)) {
            object[key] = value;
         } else if (Array.isArray(value) && !Array.isArray(object[key])) {
            object[key] = value;
         } else if (!isObject(object[key])) {
            object[key] = value;
         } else {
            mergeObject(object[key], value);
         }
      }
   }

   return object;
}

/**
 * Lodash's mergeWith implementation caused issues in Next.js environments
 * From: https://thescottyjam.github.io/snap.js/#!/nolodash/mergeWith
 * NOTE: This mutates `object`. It also may mutate anything that gets attached to `object` during the merge.
 * @param object
 * @param sources
 * @param customizer
 */
export function mergeObjectWith(object, source, customizer) {
   for (const [key, value] of Object.entries(source)) {
      const mergedValue = customizer(object[key], value, key, object, source);
      if (mergedValue !== undefined) {
         object[key] = mergedValue;
         continue;
      }
      // Otherwise, fall back to default behavior

      if (value === undefined) {
         continue;
      }

      // These checks are a week attempt at mimicking the various edge-case behaviors
      // that Lodash's `_.merge()` exhibits. Feel free to simplify and
      // remove checks that you don't need.
      if (!isPlainObject(value) && !Array.isArray(value)) {
         object[key] = value;
      } else if (Array.isArray(value) && !Array.isArray(object[key])) {
         object[key] = value;
      } else if (!isObject(object[key])) {
         object[key] = value;
      } else {
         mergeObjectWith(object[key], value, customizer);
      }
   }

   return object;
}

export function isEqual(value1: any, value2: any): boolean {
   // Each type corresponds to a particular comparison algorithm
   const getType = (value: any) => {
      if (value !== Object(value)) return "primitive";
      if (Array.isArray(value)) return "array";
      if (value instanceof Map) return "map";
      if (value != null && [null, Object.prototype].includes(Object.getPrototypeOf(value)))
         return "plainObject";
      if (value instanceof Function) return "function";
      throw new Error(
         `deeply comparing an instance of type ${value1.constructor?.name} is not supported.`,
      );
   };

   const type = getType(value1);
   if (type !== getType(value2)) {
      return false;
   }

   if (type === "primitive") {
      return value1 === value2 || (Number.isNaN(value1) && Number.isNaN(value2));
   } else if (type === "array") {
      return (
         value1.length === value2.length &&
         value1.every((iterValue: any, i: number) => isEqual(iterValue, value2[i]))
      );
   } else if (type === "map") {
      // In this particular implementation, map keys are not
      // being deeply compared, only map values.
      return (
         value1.size === value2.size &&
         [...value1].every(([iterKey, iterValue]) => {
            return value2.has(iterKey) && isEqual(iterValue, value2.get(iterKey));
         })
      );
   } else if (type === "plainObject") {
      const value1AsMap = new Map(Object.entries(value1));
      const value2AsMap = new Map(Object.entries(value2));
      return (
         value1AsMap.size === value2AsMap.size &&
         [...value1AsMap].every(([iterKey, iterValue]) => {
            return value2AsMap.has(iterKey) && isEqual(iterValue, value2AsMap.get(iterKey));
         })
      );
   } else if (type === "function") {
      // just check signature
      return value1.toString() === value2.toString();
   } else {
      throw new Error("Unreachable");
   }
}

export function getPath(
   object: object,
   _path: string | (string | number)[],
   defaultValue: any = undefined,
): any {
   const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;

   if (path.length === 0) {
      return object;
   }

   try {
      const [head, ...tail] = path;
      if (!head || !(head in object)) {
         return defaultValue;
      }

      return getPath(object[head], tail, defaultValue);
   } catch (error) {
      if (typeof defaultValue !== "undefined") {
         return defaultValue;
      }

      throw new Error(`Invalid path: ${path.join(".")}`);
   }
}

export function setPath(object: object, _path: string | (string | number)[], value: any) {
   let path = _path;
   // Optional string-path support.
   // You can remove this `if` block if you don't need it.
   if (typeof path === "string") {
      const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
      path = path
         .split(/[.\[\]]+/)
         .filter((x) => x)
         .map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
         .map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
   }

   if (path.length === 0) {
      throw new Error("The path must have at least one entry in it");
   }

   const [head, ...tail] = path as any;

   if (tail.length === 0) {
      object[head] = value;
      return object;
   }

   if (!(head in object)) {
      object[head] = typeof tail[0] === "number" ? [] : {};
   }

   setPath(object[head], tail, value);
   return object;
}

export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
   const nl = indent ? "\n" : "";
   const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
   const openPad = pad(_level + 1);
   const closePad = pad(_level);

   // primitives
   if (value === null) return "null";
   if (value === undefined) return "undefined";
   const t = typeof value;
   if (t === "string") return JSON.stringify(value); // handles escapes
   if (t === "number" || t === "boolean") return String(value);

   // arrays
   if (Array.isArray(value)) {
      const out = value
         .map((v) => objectToJsLiteral(v, indent, _level + 1))
         .join(", " + (indent ? nl + openPad : ""));
      return (
         "[" +
         (indent && value.length ? nl + openPad : "") +
         out +
         (indent && value.length ? nl + closePad : "") +
         "]"
      );
   }

   // objects
   if (t === "object") {
      const entries = Object.entries(value).map(([k, v]) => {
         const idOk = /^[A-Za-z_$][\w$]*$/.test(k); // valid identifier?
         const key = idOk ? k : JSON.stringify(k); // quote if needed
         return key + ": " + objectToJsLiteral(v, indent, _level + 1);
      });
      const out = entries.join(", " + (indent ? nl + openPad : ""));
      return (
         "{" +
         (indent && entries.length ? nl + openPad : "") +
         out +
         (indent && entries.length ? nl + closePad : "") +
         "}"
      );
   }

   throw new TypeError(`Unsupported data type: ${t}`);
}

// lodash-es compatible `pick` with perfect type inference
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
   return keys.reduce(
      (acc, key) => {
         if (key in obj) {
            acc[key] = obj[key];
         }
         return acc;
      },
      {} as Pick<T, K>,
   );
}

export function deepFreeze<T extends object>(object: T): T {
   if (Object.isFrozen(object)) return object;

   // Retrieve the property names defined on object
   const propNames = Reflect.ownKeys(object);

   // Freeze properties before freezing self
   for (const name of propNames) {
      const value = object[name];

      if ((value && typeof value === "object") || typeof value === "function") {
         deepFreeze(value);
      }
   }

   return Object.freeze(object);
}

export function convertNumberedObjectToArray(obj: object): any[] | object {
   if (Object.keys(obj).every((key) => Number.isInteger(Number(key)))) {
      return Object.values(obj);
   }
   return obj;
}

export function recursivelyReplacePlaceholders(
   obj: any,
   pattern: RegExp,
   variables: Record<string, any>,
   fallback?: any,
) {
   if (typeof obj === "string") {
      // check if the entire string matches the pattern
      const match = obj.match(pattern);
      if (match && match[0] === obj && match[1]) {
         // full string match - replace with the actual value (preserving type)
         const key = match[1];
         const value = getPath(variables, key, null);
         return value !== null ? value : fallback !== undefined ? fallback : obj;
      }
      // partial match - use string replacement
      if (pattern.test(obj)) {
         return obj.replace(pattern, (match, key) => {
            const value = getPath(variables, key, null);
            // convert to string for partial replacements
            return value !== null
               ? String(value)
               : fallback !== undefined
                 ? String(fallback)
                 : match;
         });
      }
   }
   if (Array.isArray(obj)) {
      return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables, fallback));
   }
   if (obj && typeof obj === "object") {
      return Object.entries(obj).reduce((acc, [key, value]) => {
         acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback);
         return acc;
      }, {} as object);
   }
   return obj;
}
