import saveAs from 'file-saver';
import { countBy, chain } from 'lodash';
import { type MouseEvent } from 'react';
import { lastValueFrom, map, type Observable } from 'rxjs';

import {
  LogLevel,
  type LogRowModel,
  type LogLabelStatsModel,
  type LogsModel,
  LogsSortOrder,
  type DataFrame,
  type FieldConfig,
  FieldCache,
  FieldType,
  MutableDataFrame,
  type QueryResultMeta,
  LogsVolumeType,
  NumericLogLevel,
  getFieldDisplayName,
  getDefaultTimeRange,
  locationUtil,
  urlUtil,
  dateTime,
  dateTimeFormat,
  type DataTransformerConfig,
  type CustomTransformOperator,
  transformDataFrame,
  getTimeField,
  type Field,
  type LogsMetaItem,
  store,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { getConfig } from 'app/core/config';

import { getLogsExtractFields } from '../explore/Logs/LogsTable';
import { downloadDataFrameAsCsv, downloadLogsModelAsTxt } from '../inspector/utils/download';

import { LOG_LINE_BODY_FIELD_NAME } from './components/fieldSelector/logFields';
import { getDataframeFields } from './components/logParser';
import { type GetRowContextQueryFn } from './components/panel/LogLineMenu';
import { DATAPLANE_LABELS_NAME, DATAPLANE_LABEL_TYPES_NAME, parseLogsFrame } from './logsFrame';

/**
 * Returns the log level of a log line.
 * Parse the line for level words. If no level is found, it returns `LogLevel.unknown`.
 *
 * Example: `getLogLevel('WARN 1999-12-31 this is great') // LogLevel.warn`
 */
export function getLogLevel(line: string): LogLevel {
  if (!line) {
    return LogLevel.unknown;
  }
  let level = LogLevel.unknown;
  let currentIndex: number | undefined = undefined;

  for (const [key, value] of Object.entries(LogLevel)) {
    const regexp = new RegExp(`\\b${key}\\b`, 'i');
    const result = regexp.exec(line);

    if (result) {
      if (currentIndex === undefined || result.index < currentIndex) {
        level = value;
        currentIndex = result.index;
      }
    }
  }
  return level;
}

export function getLogLevelFromKey(key: string | number): LogLevel {
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
  const level = LogLevel[key.toString().toLowerCase() as keyof typeof LogLevel];
  if (level) {
    return level;
  }
  if (typeof key === 'string') {
    // The level did not match any entry of LogLevel. It might be unknown or a numeric level.
    const numericLevel = parseInt(key, 10);
    // Safety check to confirm that we're parsing a number and not a number with a string.
    // For example `parseInt('1abcd', 10)` outputs 1
    if (key.length === numericLevel.toString().length) {
      return NumericLogLevel[key] || LogLevel.unknown;
    }
  } else if (typeof key === 'number') {
    return NumericLogLevel[key] || LogLevel.unknown;
  }

  return LogLevel.unknown;
}

export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] {
  // Consider only rows that have the given label
  const rowsWithLabel = rows.filter((row) => row.labels[label] !== undefined);
  const rowCount = rowsWithLabel.length;

  // Get label value counts for eligible rows
  const countsByValue = countBy(rowsWithLabel, (row) => row.labels[label]);
  return getSortedCounts(countsByValue, rowCount);
}

export function calculateStats(values: unknown[]): LogLabelStatsModel[] {
  const nonEmptyValues = values.filter((value) => value !== undefined && value !== null);
  const countsByValue = countBy(nonEmptyValues);
  return getSortedCounts(countsByValue, nonEmptyValues.length);
}

const getSortedCounts = (countsByValue: { [value: string]: number }, rowCount: number) => {
  return chain(countsByValue)
    .map((count, value) => ({ count, value, proportion: count / rowCount }))
    .sortBy('count')
    .reverse()
    .value();
};

export const sortInAscendingOrder = (a: LogRowModel, b: LogRowModel) => {
  // compare milliseconds
  if (a.timeEpochMs < b.timeEpochMs) {
    return -1;
  }

  if (a.timeEpochMs > b.timeEpochMs) {
    return 1;
  }

  // if milliseconds are equal, compare nanoseconds
  if (a.timeEpochNs < b.timeEpochNs) {
    return -1;
  }

  if (a.timeEpochNs > b.timeEpochNs) {
    return 1;
  }

  return 0;
};

export const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => {
  // compare milliseconds
  if (a.timeEpochMs > b.timeEpochMs) {
    return -1;
  }

  if (a.timeEpochMs < b.timeEpochMs) {
    return 1;
  }

  // if milliseconds are equal, compare nanoseconds
  if (a.timeEpochNs > b.timeEpochNs) {
    return -1;
  }

  if (a.timeEpochNs < b.timeEpochNs) {
    return 1;
  }

  return 0;
};

export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSortOrder): LogsModel => {
  const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : [];
  return logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows };
};

export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) =>
  sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder);

// Currently supports only error condition in Loki logs
export const checkLogsError = (logRow: LogRowModel): string | undefined => {
  return logRow.labels.__error__;
};

export const checkLogsSampled = (logRow: LogRowModel): string | undefined => {
  if (!logRow.labels.__adaptive_logs_sampled__) {
    return undefined;
  }
  return logRow.labels.__adaptive_logs_sampled__ === 'true'
    ? 'Logs like this one have been dropped by Adaptive Logs'
    : `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`;
};

export const escapeUnescapedString = (string: string) =>
  string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));

export function logRowsToReadableJson(logs: LogRowModel[], pickFields: string[] = []) {
  return logs.map((log) => {
    const fields = getDataframeFields(log).reduce<Record<string, string>>((acc, field) => {
      const key = field.keys[0];
      acc[key] = field.values[0];
      return acc;
    }, {});

    let logFields = {
      ...fields,
      ...log.labels,
    };

    if (pickFields.length) {
      logFields = Object.fromEntries(Object.entries(logFields).filter(([key]) => pickFields.includes(key)));
    }

    return {
      line: log.entry,
      timestamp: log.timeEpochNs,
      date: dateTime(log.timeEpochMs).toISOString(),
      fields: logFields,
    };
  });
}

export const getLogsVolumeMaximumRange = (dataFrames: DataFrame[]) => {
  let widestRange = { from: Infinity, to: -Infinity };

  dataFrames.forEach((dataFrame: DataFrame) => {
    const meta = dataFrame.meta?.custom || {};
    if (meta.absoluteRange?.from && meta.absoluteRange?.to) {
      widestRange = {
        from: Math.min(widestRange.from, meta.absoluteRange.from),
        to: Math.max(widestRange.to, meta.absoluteRange.to),
      };
    }
  });

  return widestRange;
};

/**
 * Merge data frames by level and calculate maximum total value for all levels together
 */
export const mergeLogsVolumeDataFrames = (dataFrames: DataFrame[]): { dataFrames: DataFrame[]; maximum: number } => {
  if (dataFrames.length === 0) {
    throw new Error('Cannot aggregate data frames: there must be at least one data frame to aggregate');
  }

  // aggregate by level (to produce data frames)
  const aggregated: Record<string, Record<number, number>> = {};

  // aggregate totals to align Y axis when multiple log volumes are shown
  const totals: Record<number, number> = {};
  let maximumValue = -Infinity;

  const configs: Record<
    string,
    { meta?: QueryResultMeta; valueFieldConfig: FieldConfig; timeFieldConfig: FieldConfig }
  > = {};
  let results: DataFrame[] = [];

  // collect and aggregate into aggregated object
  dataFrames.forEach((dataFrame) => {
    const { level, valueField, timeField } = getLogLevelInfo(dataFrame, dataFrames);

    if (!timeField || !valueField) {
      return;
    }

    configs[level] = {
      meta: dataFrame.meta,
      valueFieldConfig: valueField?.config ?? {},
      timeFieldConfig: timeField?.config ?? {},
    };

    for (let pointIndex = 0; pointIndex < dataFrame.length; pointIndex++) {
      const time: number = timeField.values[pointIndex];
      const value: number = valueField.values[pointIndex];
      aggregated[level] ??= {};
      aggregated[level][time] = (aggregated[level][time] || 0) + value;

      totals[time] = (totals[time] || 0) + value;
      if (totals[time] > maximumValue) {
        maximumValue = totals[time];
      }
    }
  });

  // convert aggregated into data frames
  Object.keys(aggregated).forEach((level) => {
    const levelDataFrame = new MutableDataFrame();
    const { meta, timeFieldConfig, valueFieldConfig } = configs[level];
    // Log Volume visualization uses the name when toggling the legend
    levelDataFrame.name = level;
    levelDataFrame.meta = meta;
    levelDataFrame.addField({ name: 'Time', type: FieldType.time, config: timeFieldConfig });
    levelDataFrame.addField({ name: 'Value', type: FieldType.number, config: valueFieldConfig });

    Object.entries(aggregated[level])
      .sort((a, b) => Number(a[0]) - Number(b[0]))
      .forEach(([time, value]) => {
        levelDataFrame.add({
          Time: Number(time),
          Value: value,
        });
      });

    results.push(levelDataFrame);
  });

  return { dataFrames: results, maximum: maximumValue };
};

export const getLogsVolumeDataSourceInfo = (dataFrames: DataFrame[]): { name: string } | null => {
  const customMeta = dataFrames[0]?.meta?.custom;

  if (customMeta && customMeta.datasourceName) {
    return {
      name: customMeta.datasourceName,
    };
  }

  return null;
};

export const isLogsVolumeLimited = (dataFrames: DataFrame[]) => {
  return dataFrames[0]?.meta?.custom?.logsVolumeType === LogsVolumeType.Limited;
};

export const copyText = async (text: string, buttonRef: React.MutableRefObject<Element | null>) => {
  if (navigator.clipboard && window.isSecureContext) {
    return navigator.clipboard.writeText(text);
  } else {
    // Use a fallback method for browsers/contexts that don't support the Clipboard API.
    // See https://web.dev/async-clipboard/#feature-detection.
    // Use textarea so the user can copy multi-line content.
    const textarea = document.createElement('textarea');
    // Normally we'd append this to the body. However if we're inside a focus manager
    // from react-aria, we can't focus anything outside of the managed area.
    // Instead, let's append it to the button. Then we're guaranteed to be able to focus + copy.
    buttonRef.current?.appendChild(textarea);
    textarea.value = text;
    textarea.focus();
    textarea.select();
    document.execCommand('copy');
    textarea.remove();
  }
};

export async function handleOpenLogsContextClick(
  event: MouseEvent<HTMLElement>,
  row: LogRowModel,
  getRowContextQuery: GetRowContextQueryFn | undefined,
  onOpenContext: (row: LogRowModel) => void
) {
  // if ctrl or meta key is pressed, open query in new Explore tab
  if (getRowContextQuery && (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey || event.nativeEvent.shiftKey)) {
    const win = window.open('about:blank');
    // for this request we don't want to use the cached filters from a context provider, but always want to refetch and clear
    const query = await getRowContextQuery(row, undefined, false);
    if (query && win) {
      const url = urlUtil.renderUrl(locationUtil.assureBaseUrl(`${getConfig().appSubUrl}explore`), {
        left: JSON.stringify({
          datasource: query.datasource,
          queries: [query],
          range: getDefaultTimeRange(),
        }),
      });
      win.location = url;

      return;
    }
    win?.close();
  }
  onOpenContext(row);
}

export function getLogLevelInfo(dataFrame: DataFrame, allDataFrames: DataFrame[]) {
  const fieldCache = new FieldCache(dataFrame);
  const timeField = fieldCache.getFirstFieldOfType(FieldType.time);
  const valueField = fieldCache.getFirstFieldOfType(FieldType.number);

  if (!timeField) {
    console.error('Time field missing in data frame');
  }
  if (!valueField) {
    console.error('Value field missing in data frame');
  }

  const level = valueField ? getFieldDisplayName(valueField, dataFrame, allDataFrames) : 'logs';
  return { level, valueField, timeField };
}

export function targetIsElement(target: EventTarget | null): target is Element {
  return target instanceof Element;
}

export function createLogRowsMap() {
  const logRowsSet = new Set();
  return function (target: LogRowModel): boolean {
    let id = `${target.dataFrame.refId}_${target.rowId ? target.rowId : `${target.timeEpochNs}_${target.entry}`}`;
    if (logRowsSet.has(id)) {
      return true;
    }
    logRowsSet.add(id);
    return false;
  };
}

function getLabelDisplayTypeFromFrame(labelKey: string, frame: DataFrame, index: number): null | string {
  const typeField = frame.fields.find((field) => field.name === 'labelTypes')?.values[index];
  if (!typeField) {
    return null;
  }
  return typeField[labelKey] ?? null;
}

/**
 * @deprecated Implement DataSourceWithLogsLabelTypesSupport and use ds.getLabelDisplayTypeFromFrame
 * to support label types.
 *
 * Look for a `labelTypes` field. If found, use it to resolve the type for the
 * provided label.
 *
 * @param label
 * @param row
 * @param plural
 */
export function getLabelTypeFromRow(label: string, row: LogRowModel, plural = false) {
  const labelType = getLabelDisplayTypeFromFrame(label, row.dataFrame, row.rowIndex);
  if (!labelType) {
    return null;
  }
  return getDataSourceLabelType(labelType, row.datasourceType, plural);
}

/**
 * @deprecated
 * @param labelType
 * @param datasourceType
 * @param plural
 */
function getDataSourceLabelType(labelType: string, datasourceType: string | undefined, plural: boolean) {
  switch (datasourceType) {
    case 'loki':
      switch (labelType) {
        case 'I':
          return t('logs.fields.type.loki.indexed-label', 'Indexed labels', { count: plural ? 2 : 1 });
        case 'S':
          return t('logs.fields.type.loki.structured-metadata', 'Structured metadata', { count: plural ? 2 : 1 });
        case 'P':
          return t('logs.fields.type.loki.parsedl-label', 'Parsed fields', { count: plural ? 2 : 1 });
        default:
          return null;
      }
    default:
      return null;
  }
}

const POPOVER_STORAGE_KEY = 'logs.popover.disabled';
export function disablePopoverMenu() {
  store.set(POPOVER_STORAGE_KEY, 'true');
}

export function enablePopoverMenu() {
  store.delete(POPOVER_STORAGE_KEY);
}

export function isPopoverMenuDisabled() {
  return Boolean(store.get(POPOVER_STORAGE_KEY));
}

export enum DownloadFormat {
  Text = 'text',
  Json = 'json',
  CSV = 'csv',
}

export const downloadLogs = async (
  format: DownloadFormat,
  logRows: LogRowModel[],
  meta?: LogsMetaItem[],
  fields: string[] = []
) => {
  switch (format) {
    case DownloadFormat.Text:
      const shouldInjectLogLineBodyField = fields.length > 0 && fields.includes(LOG_LINE_BODY_FIELD_NAME);
      const rowsForDownload = shouldInjectLogLineBodyField
        ? logRows.map((row) => ({
            ...row,
            labels: {
              ...row.labels,
              [LOG_LINE_BODY_FIELD_NAME]: row.entry,
            },
          }))
        : logRows;
      downloadLogsModelAsTxt({ meta, rows: rowsForDownload }, '', fields);
      break;
    case DownloadFormat.Json:
      const jsonLogs = logRowsToReadableJson(logRows, fields);
      const blob = new Blob([JSON.stringify(jsonLogs)], {
        type: 'application/json;charset=utf-8',
      });
      const fileName = `Logs-${dateTimeFormat(new Date())}.json`;
      saveAs(blob, fileName);
      break;
    case DownloadFormat.CSV: {
      const dataFrameMap = new Map<string, DataFrame>();
      logRows.forEach((row) => {
        if (row.dataFrame?.refId && !dataFrameMap.has(row.dataFrame?.refId)) {
          dataFrameMap.set(row.dataFrame?.refId, row.dataFrame);
        }
      });
      for (const dataFrame of dataFrameMap.values()) {
        const transforms: Array<DataTransformerConfig | CustomTransformOperator> = getLogsExtractFields(dataFrame);
        if (fields.length) {
          const logsFrame = parseLogsFrame(dataFrame);
          const bodyFieldName = logsFrame?.bodyField.name;
          const csvFieldNames = fields.map((name) =>
            name === LOG_LINE_BODY_FIELD_NAME && bodyFieldName ? bodyFieldName : name
          );
          transforms.push(addISODateTransformation, {
            id: 'filterFieldsByName',
            options: {
              include: {
                names: ['Date', ...csvFieldNames],
              },
            },
          });
        } else {
          transforms.push(
            {
              id: 'organize',
              options: {
                excludeByName: {
                  [DATAPLANE_LABELS_NAME]: true,
                  [DATAPLANE_LABEL_TYPES_NAME]: true,
                },
              },
            },
            addISODateTransformation
          );
        }
        const transformedDataFrame = await lastValueFrom(transformDataFrame(transforms, [dataFrame]));
        downloadDataFrameAsCsv(transformedDataFrame[0], `Logs-${dataFrame.refId}`);
      }
      break;
    }
  }
};

const addISODateTransformation: CustomTransformOperator = () => (source: Observable<DataFrame[]>) => {
  return source.pipe(
    map((data: DataFrame[]) => {
      return data.map((frame: DataFrame) => {
        const timeField = getTimeField(frame);
        const field: Field = {
          name: 'Date',
          values: timeField.timeField ? timeField.timeField?.values.map((v) => dateTime(v).toISOString()) : [],
          type: FieldType.other,
          config: {},
        };
        return {
          ...frame,
          fields: [field, ...frame.fields],
        };
      });
    })
  );
};

/**
 * Get the start and end timestamps from series.
 */
export function getLogsVisibleRange(logs: LogRowModel[]) {
  let start = 0;
  let end = 0;
  if (logs.length > 1) {
    const values = [logs[0].timeEpochMs, logs[logs.length - 1].timeEpochMs].sort();
    start = values[0];
    end = values[values.length - 1];
  }
  return { end, start };
}
