import { first, uniqBy } from 'lodash';
import { useCallback } from 'react';

import {
  type Field,
  type LinkModel,
  type TimeRange,
  mapInternalLinkToExplore,
  type InterpolateFunction,
  type ScopedVars,
  type DataFrame,
  getFieldDisplayValuesProxy,
  type SplitOpen,
  type DataLink,
  type DisplayValue,
  DataLinkConfigOrigin,
  CoreApp,
  type SplitOpenOptions,
  type DataLinkPostProcessor,
  type ExploreUrlState,
  urlUtil,
  DataFrameType,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { getTemplateSrv, reportInteraction, type VariableInterpolation } from '@grafana/runtime';
import { type DataQuery } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv';
import { getTransformationVars } from 'app/features/correlations/transformations';
import { parseDataplaneLogsFrame } from 'app/features/logs/logsFrame';
import { type ExploreItemState } from 'app/types/explore';

import { getLinkSrv } from '../../panel/panellinks/link_srv';
import { getUrlStateFromPaneState } from '../hooks/useStateSync/external.utils';

type DataLinkFilter = (link: DataLink, scopedVars: ScopedVars) => boolean;

const dataLinkHasRequiredPermissionsFilter = (link: DataLink) => {
  return !link.internal || contextSrv.hasAccessToExplore();
};

/**
 * Fixed list of filters used in Explore. DataLinks that do not pass all the filters will not
 * be passed back to the visualization.
 */
const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasRequiredPermissionsFilter];

/**
 * This extension of the LinkModel was done to support correlations, which need the variables' names
 * and values split out for display purposes
 *
 * Correlations are internal links only so the variables property will always be defined (but possibly empty)
 * for internal links and undefined for non-internal links
 */
export interface ExploreFieldLinkModel extends LinkModel<Field> {
  variables: VariableInterpolation[];
}

const DATA_LINK_USAGE_KEY = 'grafana_data_link_clicked';

/**
 * Creates an internal link supplier specific to Explore
 */
export const exploreDataLinkPostProcessorFactory = (
  splitOpenFn: SplitOpen | undefined,
  range: TimeRange
): DataLinkPostProcessor => {
  const exploreDataLinkPostProcessor: DataLinkPostProcessor = (options) => {
    const { field, dataLinkScopedVars: vars, frame: dataFrame, link, linkModel } = options;
    const { valueRowIndex: rowIndex } = options.config;

    if (rowIndex === undefined) {
      return linkModel;
    }

    /**
     * Even though getFieldLinksForExplore can produce internal and external links we re-use the logic for creating
     * internal links only. Eventually code from getFieldLinksForExplore can be moved here and getFieldLinksForExplore
     * can be removed (once all Explore panels start using field.getLinks).
     */
    const links = getFieldLinksForExplore({
      field,
      rowIndex,
      splitOpenFn,
      range,
      vars,
      dataFrame,
      linksToProcess: [link],
    });

    return links.length ? first(links) : undefined;
  };
  return exploreDataLinkPostProcessor;
};

/**
 * Get links from the field of a dataframe and in addition check if there is associated
 * metadata with datasource in which case we will add onClick to open the link in new split window. This assumes
 * that we just supply datasource name and field value and Explore split window will know how to render that
 * appropriately. This is for example used for transition from log with traceId to trace datasource to show that
 * trace.
 *
 * Note: accessing a field via ${__data.fields.variable} will stay consistent with dashboards and return as existing but with an empty string
 * Accessing a field with ${variable} will return undefined as this is unique to explore.
 * @deprecated Use field.getLinks directly
 */
export const getFieldLinksForExplore = (options: {
  field: Field;
  rowIndex: number;
  splitOpenFn?: SplitOpen;
  range: TimeRange;
  vars?: ScopedVars;
  dataFrame?: DataFrame;
  // if not provided, field.config.links are used
  linksToProcess?: DataLink[];
}): ExploreFieldLinkModel[] => {
  const { field, vars, splitOpenFn, range, rowIndex, dataFrame } = options;
  const scopedVars: ScopedVars = { ...(vars || {}) };
  scopedVars['__value'] = {
    value: {
      raw: field.values[rowIndex],
    },
    text: t('explore.get-field-links-for-explore.text.raw-value', 'Raw value'),
  };

  let fieldDisplayValuesProxy: Record<string, DisplayValue> | undefined = undefined;

  // If we have a dataFrame we can allow referencing other columns and their values in the interpolation.
  if (dataFrame) {
    fieldDisplayValuesProxy = getFieldDisplayValuesProxy({
      frame: dataFrame,
      rowIndex,
    });

    scopedVars['__data'] = {
      value: {
        name: dataFrame.name,
        refId: dataFrame.refId,
        fields: fieldDisplayValuesProxy,
      },
      text: t('explore.get-field-links-for-explore.text.data', 'Data'),
    };

    if (dataFrame.meta?.type === DataFrameType.LogLines) {
      const dataPlane = parseDataplaneLogsFrame(dataFrame);
      const labels = dataPlane?.getLogFrameLabels();
      if (labels != null) {
        Object.entries(labels[rowIndex]).forEach((value) => {
          scopedVars[value[0]] = {
            value: value[1],
          };
        });
      }
    }

    dataFrame.fields.forEach((f) => {
      if (fieldDisplayValuesProxy && fieldDisplayValuesProxy[f.name]) {
        scopedVars[f.name] = {
          value: fieldDisplayValuesProxy[f.name],
        };
      }
    });

    // add this for convenience
    scopedVars['__targetField'] = {
      value: fieldDisplayValuesProxy[field.name],
    };
  }

  const linksToProcess = options.linksToProcess || field.config.links;

  if (linksToProcess) {
    const links = linksToProcess.filter((link) => {
      return DATA_LINK_FILTERS.every((filter) => filter(link, scopedVars));
    });

    const fieldLinks = links.map((link) => {
      let internalLinkSpecificVars: ScopedVars = {};
      if (link.meta?.transformations) {
        link.meta?.transformations.forEach((transformation) => {
          let fieldValue;
          if (transformation.field) {
            const transformField = dataFrame?.fields.find((field) => field.name === transformation.field);
            fieldValue = transformField?.values[rowIndex];
          } else {
            fieldValue = field.values[rowIndex];
          }

          internalLinkSpecificVars = {
            ...internalLinkSpecificVars,
            ...getTransformationVars(transformation, fieldValue, field.name),
          };
        });
      }

      const allVars = { ...scopedVars, ...internalLinkSpecificVars };
      const variableData = getVariableUsageInfo(link, allVars);
      let variables: VariableInterpolation[] = [];

      // if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with
      if (variableData.variables.length === 0) {
        const fieldName = field.name.toString();
        variables.push({ variableName: fieldName, value: '', match: '' });
      } else {
        variables = variableData.variables;
      }
      if (variableData.allVariablesDefined) {
        if (!link.internal) {
          const replace: InterpolateFunction = (value, vars) =>
            getTemplateSrv().replace(value, { ...vars, ...allVars, ...scopedVars });

          const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field);
          if (!linkModel.title) {
            linkModel.title = getTitleFromHref(linkModel.href);
          }
          linkModel.target = linkModel.target ?? '_blank';
          return { ...linkModel, variables: variables };
        } else {
          const splitFnWithTracking = (options?: SplitOpenOptions<DataQuery>) => {
            reportInteraction(DATA_LINK_USAGE_KEY, {
              origin: link.origin || DataLinkConfigOrigin.Datasource,
              app: CoreApp.Explore,
              internal: true,
            });

            splitOpenFn?.(options);
          };

          const internalLink = mapInternalLinkToExplore({
            link,
            internalLink: link.internal,
            scopedVars: allVars,
            range,
            field,
            // Don't track internal links without split view as they are used only in Dashboards
            onClickFn: options.splitOpenFn ? (options) => splitFnWithTracking(options) : undefined,
            replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
          });
          return { ...internalLink, variables: variables };
        }
      } else {
        return undefined;
      }
    });
    return fieldLinks.filter((link): link is ExploreFieldLinkModel => !!link);
  }
  return [];
};

/**
 * @internal
 */
function getTitleFromHref(href: string): string {
  // The URL constructor needs the url to have protocol
  if (href.indexOf('://') < 0) {
    // Doesn't really matter what protocol we use.
    href = `http://${href}`;
  }
  let title;
  try {
    const parsedUrl = new URL(href);
    title = parsedUrl.hostname;
  } catch (_e) {
    // Should be good enough fallback, user probably did not input valid url.
    title = href;
  }
  return title;
}

/**
 * Hook that returns a function that can be used to retrieve all the links for a row. This returns all the links from
 * all the fields so is useful for visualisation where the whole row is represented as single clickable item like a
 * service map.
 */
export function useLinks(range: TimeRange, splitOpenFn?: SplitOpen) {
  return useCallback(
    (dataFrame: DataFrame, rowIndex: number) => {
      return dataFrame.fields.flatMap((f) => {
        if (f.config?.links && f.config?.links.length) {
          return getFieldLinksForExplore({
            field: f,
            rowIndex: rowIndex,
            range,
            dataFrame,
            splitOpenFn,
          });
        } else {
          return [];
        }
      });
    },
    [range, splitOpenFn]
  );
}

// See https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables
const builtInVariables = [
  '__from',
  '__to',
  '__interval',
  '__interval_ms',
  '__org',
  '__user',
  '__range',
  '__rate_interval',
  '__timeFilter',
  'timeFilter',
  // These are only applicable in dashboards so should not affect this for Explore
  // '__dashboard',
  //'__name',
];

/**
 * Use variable map from templateSrv to determine if all variables have values
 * @param query
 * @param scopedVars
 */
export function getVariableUsageInfo(
  query: object,
  scopedVars: ScopedVars
): { variables: VariableInterpolation[]; allVariablesDefined: boolean } {
  let variables: VariableInterpolation[] = [];
  const replaceFn = getTemplateSrv().replace.bind(getTemplateSrv());
  // This adds info to the variables array while interpolating
  replaceFn(getStringsFromObject(query), scopedVars, undefined, variables);
  variables = uniqBy(variables, 'variableName');
  return {
    variables: variables,
    allVariablesDefined: variables
      // We filter out builtin variables as they should be always defined but sometimes only later, like
      // __range_interval which is defined in prometheus at query time.
      .filter((v) => !builtInVariables.includes(v.variableName))
      .every((variable) => variable.found),
  };
}

// Recursively get all strings from an object into a simple list with space as separator.
function getStringsFromObject(obj: Object): string {
  let acc = '';
  let k: keyof typeof obj;

  for (k in obj) {
    if (typeof obj[k] === 'string') {
      acc += ' ' + obj[k];
    } else if (typeof obj[k] === 'object') {
      acc += ' ' + getStringsFromObject(obj[k]);
    }
  }
  return acc;
}

type StateEntry = [string, ExploreItemState];
const isStateEntry = (entry: [string, ExploreItemState | undefined]): entry is StateEntry => {
  return entry[1] !== undefined;
};

export const constructAbsoluteUrl = (panes: Record<string, ExploreItemState | undefined>) => {
  const urlStates = Object.entries(panes)
    .filter(isStateEntry)
    .map(([exploreId, pane]) => {
      const urlState = getUrlStateFromPaneState(pane);
      urlState.range = {
        to: pane.range.to.valueOf().toString(),
        from: pane.range.from.valueOf().toString(),
      };
      const panes: [string, ExploreUrlState] = [exploreId, urlState];
      return panes;
    })
    .reduce((acc, [exploreId, urlState]) => {
      return { ...acc, [exploreId]: urlState };
    }, {});
  return urlUtil.renderUrl('/explore', { schemaVersion: 1, panes: JSON.stringify(urlStates) });
};
