import { createTwoFilesPatch } from 'diff';

import { type Dashboard } from '@grafana/schema';

import { DashboardModel } from '../../state/DashboardModel';

export type JSONValue = null | boolean | number | string | JSONArray | JSONObject;

export type JSONArray = JSONValue[];

export type JSONObject = {
  [key: string]: JSONValue;
};

export function orderProperties(obj1: JSONValue, obj2: JSONValue) {
  // If obj1 and obj2 are the same object, return obj2
  if (obj1 === obj2) {
    return obj2; // No need to order properties, they are already the same
  }

  if (Array.isArray(obj1) && Array.isArray(obj2)) {
    // They are both arrays
    return orderArrayProperties(obj1, obj2);
  }

  // Use a type guard to check if they are both non-array objects
  else if (isObject(obj1) && isObject(obj2)) {
    // Both non-array objects
    return orderObjectProperties(obj1, obj2);
  }

  return obj2;
}

export function isObject(obj: JSONValue): obj is JSONObject {
  return typeof obj === 'object' && !Array.isArray(obj) && obj !== null;
}

export function orderObjectProperties(obj1: JSONObject, obj2: JSONObject) {
  const orderedProperties = Object.keys(obj1);
  const orderedObj2: Record<string, JSONValue> = {};

  for (const prop of orderedProperties) {
    if (obj2.hasOwnProperty(prop)) {
      if (Array.isArray(obj1[prop]) && Array.isArray(obj2[prop])) {
        // Recursive call orderProperties for arrays
        orderedObj2[prop] = orderProperties(obj1[prop], obj2[prop]);
      } else if (typeof obj1[prop] === 'object' && typeof obj2[prop] === 'object') {
        // Recursively call orderProperties for nested objects
        orderedObj2[prop] = orderProperties(obj1[prop], obj2[prop]);
      } else {
        orderedObj2[prop] = obj2[prop];
      }
    }
  }
  return orderedObj2;
}

export function orderArrayProperties(obj1: JSONArray, obj2: JSONArray) {
  const orderedObj2: JSONValue[] = new Array(obj1.length).fill(undefined);

  const unseen1 = new Set<number>([...Array(obj1.length).keys()]);
  const unseen2 = new Set<number>([...Array(obj2.length).keys()]);

  // Loop to match up elements that match exactly
  for (let i = 0; i < obj1.length; i++) {
    if (unseen2.size === 0) {
      break;
    }
    let item1 = obj1[i];
    for (let j = 0; j < obj2.length; j++) {
      if (!unseen2.has(j)) {
        continue;
      }
      let item2 = obj2[j];
      item2 = orderProperties(item1, item2);
      if (JSON.stringify(item1) === JSON.stringify(item2)) {
        unseen1.delete(i);
        unseen2.delete(j);
        orderedObj2[i] = item2;
      }
    }
  }

  fillBySimilarity(obj1, obj2, orderedObj2, unseen1, unseen2);

  return orderedObj2.filter((value) => value !== undefined);
}

// Compare all pairings by similarity and match greedily from highest to lowest
// Similarity is simply measured by number of k:v pairs in fair
// O(n^2), which is more or less unavoidable
// Can be made a better match by using levenshtein distance and Hungarian matching
export function fillBySimilarity(
  // TODO: Investigate not using any
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  obj1: any[],
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  obj2: any[],
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  orderedObj2: any[],
  unseen1: Set<number>,
  unseen2: Set<number>
): void {
  let rankings: Record<number, number[][]> = {}; // Maps scores to arrays of value pairs
  // Unpacking it because I'm not sure removing items while iterating is safe

  unseen2.forEach((j: number) => {
    // Index name matches calling function
    let item2 = obj2[j];

    // If not object, or if array, just push item2 to orderedObj2 and remove j from unseen2
    if (typeof item2 !== 'object' || Array.isArray(item2)) {
      orderedObj2.push(item2);
      unseen2.delete(j);
      return;
    }

    unseen1.forEach((i: number) => {
      let item1 = obj1[i];
      if (typeof item1 !== 'object' || Array.isArray(item1)) {
        unseen1.delete(i);
        return;
      }

      let score = 0;

      for (const key in item1) {
        let val1 = item1[key];
        if (!item2.hasOwnProperty(key)) {
          continue;
        }
        let val2 = item2[key];
        if ((typeof val1 !== 'string' && typeof val1 !== 'number') || typeof val1 !== typeof val2) {
          continue;
        }
        if (val1 === val2) {
          if (key === 'id') {
            score += 1000; // Can probably be caught earlier in the call tree.
          }
          score++;
        }
      }

      if (score !== 0) {
        if (rankings[score] === undefined) {
          rankings[score] = [];
        }
        rankings[score].push([i, j]);
      }
    });
  });

  const keys: number[] = Object.keys(rankings).map(Number); // Get keys as an array of numbers
  keys.sort((a, b) => b - a); // Sort in descending order

  for (const key of keys) {
    let pairs: number[][] = rankings[key];
    for (const pair of pairs) {
      const [i, j] = pair;
      if (unseen1.has(i) && unseen2.has(j)) {
        orderedObj2[i] = obj2[j];
        unseen1.delete(i);
        unseen2.delete(j);
      }
    }
  }

  // Get anything that had no matches whatsoever
  for (const j of unseen2) {
    orderedObj2.push(obj2[j]);
  }
}

function shortenDiff(diffS: string) {
  const diffLines = diffS.split('\n');
  let headerEnd = diffS[0].startsWith('Index') ? 4 : 3;
  let ret = diffLines.slice(0, headerEnd);

  const titleOrBracket = /("title"|Title|\{|\}|\[|\])/i;
  for (let i = headerEnd; i < diffLines.length; i++) {
    let line = diffLines[i];
    if (titleOrBracket.test(line)) {
      ret.push(line);
    } else if (line.startsWith('+') || line.startsWith('-')) {
      ret.push(line);
    }
  }
  return ret.join('\n') + '\n';
}

export function removeEmptyFields(input: JSONValue): JSONValue {
  if (input === null || input === '') {
    return null;
  }

  if (Array.isArray(input)) {
    // Filter out empty values and recursively process the non-empty ones
    const filteredArray = input.map((item) => removeEmptyFields(item)).filter((item) => item !== null);

    return filteredArray.length > 0 ? filteredArray : null;
  }

  if (typeof input !== 'object') {
    // If it's not an object, return as is
    return input;
  }

  // For objects, recursively process each key-value pair
  const result: JSONObject = {};
  for (const key in input) {
    const processedValue = removeEmptyFields(input[key]);

    if (processedValue !== null) {
      if (Array.isArray(processedValue) && processedValue.length === 0) {
        continue;
      }

      if (typeof processedValue === 'object') {
        const keys = Object.keys(processedValue);
        if (keys.length === 0) {
          continue;
        }
      }

      result[key] = processedValue;
    }
  }

  return Object.keys(result).length > 0 ? result : null;
}

function jsonSanitize(obj: Dashboard | DashboardModel | null) {
  return JSON.parse(JSON.stringify(obj, null, 2));
}

export function getDashboardStringDiff(dashboard: DashboardModel): { migrationDiff: string; userDiff: string } {
  let originalDashboard = jsonSanitize(dashboard.getOriginalDashboard());
  let dashboardAfterMigration = jsonSanitize(new DashboardModel(originalDashboard).getSaveModelClone());
  let currentDashboard = jsonSanitize(dashboard.getSaveModelClone());

  dashboardAfterMigration = removeEmptyFields(orderProperties(originalDashboard, dashboardAfterMigration));
  currentDashboard = removeEmptyFields(orderProperties(dashboardAfterMigration, currentDashboard));
  originalDashboard = removeEmptyFields(originalDashboard);

  let migrationDiff: string = createTwoFilesPatch(
    'Before migration changes',
    'After migration changes',
    JSON.stringify(originalDashboard, null, 2),
    JSON.stringify(dashboardAfterMigration, null, 2),
    '',
    '',
    { context: 20 }
  );

  let userDiff: string = createTwoFilesPatch(
    'Before user changes',
    'After user changes',
    JSON.stringify(dashboardAfterMigration, null, 2),
    JSON.stringify(currentDashboard, null, 2),
    '',
    '',
    { context: 20 }
  );

  migrationDiff = shortenDiff(migrationDiff);
  userDiff = shortenDiff(userDiff);

  return { migrationDiff, userDiff };
}
