import { css } from '@emotion/css';
import { debounce, isEqual } from 'lodash';
import { SyntheticEvent, useReducer } from 'react';

import { AdHocVariableFilter, GrafanaTheme2, RawTimeRange, SelectableValue } from '@grafana/data';
import { config, isFetchError } from '@grafana/runtime';
import {
  AdHocFiltersVariable,
  PanelBuilders,
  SceneComponentProps,
  SceneCSSGridItem,
  SceneCSSGridLayout,
  SceneFlexItem,
  SceneFlexLayout,
  sceneGraph,
  SceneObject,
  SceneObjectBase,
  SceneObjectRef,
  SceneObjectState,
  SceneObjectStateChangedEvent,
  SceneObjectUrlSyncConfig,
  SceneObjectUrlValues,
  SceneObjectWithUrlSync,
  SceneTimeRange,
  SceneVariableSet,
  VariableDependencyConfig,
} from '@grafana/scenes';
import { Alert, Badge, Field, Icon, IconButton, InlineSwitch, Input, Select, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { getSelectedScopes } from 'app/features/scopes';

import { MetricScene } from '../MetricScene';
import { StatusWrapper } from '../StatusWrapper';
import { Node, Parser } from '../groop/parser';
import { getMetricDescription } from '../helpers/MetricDatasourceHelper';
import { reportExploreMetrics } from '../interactions';
import { setOtelExperienceToggleState } from '../services/store';
import {
  getVariablesWithMetricConstant,
  MetricSelectedEvent,
  RefreshMetricsEvent,
  VAR_DATASOURCE,
  VAR_DATASOURCE_EXPR,
  VAR_FILTERS,
} from '../shared';
import { getFilters, getTrailFor, isSceneTimeRangeState } from '../utils';

import { SelectMetricAction } from './SelectMetricAction';
import { getMetricNames } from './api';
import { getPreviewPanelFor } from './previewPanel';
import { sortRelatedMetrics } from './relatedMetrics';
import { createJSRegExpFromSearchTerms, createPromRegExp, deriveSearchTermsFromInput } from './util';

interface MetricPanel {
  name: string;
  index: number;
  itemRef?: SceneObjectRef<SceneCSSGridItem>;
  isEmpty?: boolean;
  isPanel?: boolean;
  loaded?: boolean;
}

export interface MetricSelectSceneState extends SceneObjectState {
  body: SceneFlexLayout | SceneCSSGridLayout;
  rootGroup?: Node;
  metricPrefix?: string;
  metricNames?: string[];
  metricNamesLoading?: boolean;
  metricNamesError?: string;
  metricNamesWarning?: string;
  missingOtelTargets?: boolean;
}

const ROW_PREVIEW_HEIGHT = '175px';
const ROW_CARD_HEIGHT = '64px';
const METRIC_PREFIX_ALL = 'all';

const MAX_METRIC_NAMES = 20000;

const viewByTooltip =
  'View by the metric prefix. A metric prefix is a single word at the beginning of the metric name, relevant to the domain the metric belongs to.';

export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> implements SceneObjectWithUrlSync {
  private previewCache: Record<string, MetricPanel> = {};
  private ignoreNextUpdate = false;
  private _debounceRefreshMetricNames = debounce(() => this._refreshMetricNames(), 1000);

  constructor(state: Partial<MetricSelectSceneState>) {
    super({
      $variables: state.$variables,
      metricPrefix: state.metricPrefix ?? METRIC_PREFIX_ALL,
      body:
        state.body ??
        new SceneCSSGridLayout({
          children: [],
          templateColumns: 'repeat(auto-fill, minmax(450px, 1fr))',
          autoRows: ROW_PREVIEW_HEIGHT,
          isLazy: true,
        }),
      ...state,
    });

    this.addActivationHandler(this._onActivate.bind(this));
  }

  protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['metricPrefix'] });
  protected _variableDependency = new VariableDependencyConfig(this, {
    variableNames: [VAR_DATASOURCE, VAR_FILTERS],
    onReferencedVariableValueChanged: () => {
      // In all cases, we want to reload the metric names
      this._debounceRefreshMetricNames();
    },
  });

  getUrlState() {
    return { metricPrefix: this.state.metricPrefix };
  }

  updateFromUrl(values: SceneObjectUrlValues) {
    if (typeof values.metricPrefix === 'string') {
      if (this.state.metricPrefix !== values.metricPrefix) {
        this.setState({ metricPrefix: values.metricPrefix });
      }
    }
  }

  private _onActivate() {
    if (this.state.body.state.children.length === 0) {
      this.buildLayout();
    } else {
      // Temp hack when going back to select metric scene and variable updates
      this.ignoreNextUpdate = true;
    }

    const trail = getTrailFor(this);

    this._subs.add(
      trail.subscribeToEvent(MetricSelectedEvent, (event) => {
        const { steps, currentStep } = trail.state.history.state;
        const prevStep = steps[currentStep].parentIndex;
        const previousMetric = steps[prevStep].trailState.metric;
        const isRelatedMetricSelector = previousMetric !== undefined;

        if (event.payload !== undefined) {
          const metricSearch = getMetricSearch(trail);
          const searchTermCount = deriveSearchTermsFromInput(metricSearch).length;

          reportExploreMetrics('metric_selected', {
            from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list',
            searchTermCount,
          });
        }
      })
    );

    this._subs.add(
      trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => {
        if (evt.payload.changedObject instanceof SceneTimeRange) {
          const { prevState, newState } = evt.payload;

          if (isSceneTimeRangeState(prevState) && isSceneTimeRangeState(newState)) {
            if (prevState.from === newState.from && prevState.to === newState.to) {
              return;
            }
          }
        }
      })
    );

    this._subs.add(
      trail.subscribeToState(({ metricSearch }, oldState) => {
        const oldSearchTerms = deriveSearchTermsFromInput(oldState.metricSearch);
        const newSearchTerms = deriveSearchTermsFromInput(metricSearch);
        if (!isEqual(oldSearchTerms, newSearchTerms)) {
          this._debounceRefreshMetricNames();
        }
      })
    );

    this.subscribeToState((newState, prevState) => {
      if (newState.metricNames !== prevState.metricNames) {
        this.onMetricNamesChanged();
      }
    });

    this._subs.add(
      trail.subscribeToState(({ otelTargets }, oldState) => {
        // if the otel targets have changed, get the new list of metrics
        if (
          otelTargets?.instances !== oldState.otelTargets?.instances &&
          otelTargets?.jobs !== oldState.otelTargets?.jobs
        ) {
          this._debounceRefreshMetricNames();
        }
      })
    );

    this._subs.add(
      trail.subscribeToState(() => {
        // users will most likely not switch this off but for now,
        // update metric names when changing useOtelExperience
        this._debounceRefreshMetricNames();
      })
    );

    this._subs.add(
      trail.subscribeToState(() => {
        // move showPreviews into the settings
        // build layout when toggled
        this.buildLayout();
      })
    );

    if (config.featureToggles.enableScopesInMetricsExplore) {
      this._subs.add(
        trail.subscribeToEvent(RefreshMetricsEvent, () => {
          this._debounceRefreshMetricNames();
        })
      );
    }

    this._debounceRefreshMetricNames();
  }

  private async _refreshMetricNames() {
    const trail = getTrailFor(this);
    const timeRange: RawTimeRange | undefined = trail.state.$timeRange?.state;

    if (!timeRange) {
      return;
    }

    const filters: AdHocVariableFilter[] = [];

    const filtersVar = sceneGraph.lookupVariable(VAR_FILTERS, this);
    const adhocFilters = filtersVar instanceof AdHocFiltersVariable ? (filtersVar?.state.filters ?? []) : [];
    if (adhocFilters.length > 0) {
      filters.push(...adhocFilters);
    }

    const metricSearchRegex = createPromRegExp(trail.state.metricSearch);
    if (metricSearchRegex) {
      filters.push({
        key: '__name__',
        operator: '=~',
        value: metricSearchRegex,
      });
    }

    const datasourceUid = sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR);
    this.setState({ metricNamesLoading: true, metricNamesError: undefined, metricNamesWarning: undefined });

    try {
      const jobsList = trail.state.useOtelExperience ? (trail.state.otelTargets?.jobs ?? []) : [];
      const instancesList = trail.state.useOtelExperience ? (trail.state.otelTargets?.instances ?? []) : [];

      const response = await getMetricNames(
        datasourceUid,
        timeRange,
        getSelectedScopes(),
        filters,
        jobsList,
        instancesList,
        MAX_METRIC_NAMES
      );
      const searchRegex = createJSRegExpFromSearchTerms(getMetricSearch(this));
      let metricNames = searchRegex
        ? response.data.filter((metric) => !searchRegex || searchRegex.test(metric))
        : response.data;

      // use this to generate groups for metric prefix
      const filteredMetricNames = metricNames;

      // filter the remaining metrics with the metric prefix
      const metricPrefix = this.state.metricPrefix;
      if (metricPrefix && metricPrefix !== 'all') {
        const prefixRegex = new RegExp(`(^${metricPrefix}.*)`, 'igy');
        metricNames = metricNames.filter((metric) => !prefixRegex || prefixRegex.test(metric));
      }

      let metricNamesWarning = response.limitReached
        ? `This feature will only return up to ${MAX_METRIC_NAMES} metric names for performance reasons. ` +
          `This limit is being exceeded for the current data source. ` +
          `Add search terms or label filters to narrow down the number of metric names returned.`
        : undefined;

      // if there are no otel targets for otel resources, there will be no labels
      if (trail.state.useOtelExperience && (jobsList.length === 0 || instancesList.length === 0)) {
        metricNames = [];
        metricNamesWarning = undefined;
      }

      let bodyLayout = this.state.body;

      // generate groups based on the search metrics input
      let rootGroupNode = await this.generateGroups(filteredMetricNames);

      this.setState({
        metricNames,
        rootGroup: rootGroupNode,
        body: bodyLayout,
        metricNamesLoading: false,
        metricNamesWarning,
        metricNamesError: response.error,
        missingOtelTargets: response.missingOtelTargets,
      });
    } catch (err: unknown) {
      let error = 'Unknown error';
      if (isFetchError(err)) {
        if (err.cancelled) {
          error = 'Request cancelled';
        } else if (err.statusText) {
          error = err.statusText;
        }
      }

      this.setState({ metricNames: undefined, metricNamesLoading: false, metricNamesError: error });
    }
  }

  private async generateGroups(metricNames: string[] = []) {
    const groopParser = new Parser();
    groopParser.config = {
      ...groopParser.config,
      maxDepth: 2,
      minGroupSize: 2,
      miscGroupKey: 'misc',
    };
    const { root: rootGroupNode } = groopParser.parse(metricNames);
    return rootGroupNode;
  }

  private onMetricNamesChanged() {
    const metricNames = this.state.metricNames || [];

    const nameSet = new Set(metricNames);

    Object.values(this.previewCache).forEach((panel) => {
      if (!nameSet.has(panel.name)) {
        panel.isEmpty = true;
      }
    });

    const trail = getTrailFor(this);
    const sortedMetricNames =
      trail.state.metric !== undefined ? sortRelatedMetrics(metricNames, trail.state.metric) : metricNames;
    const metricsMap: Record<string, MetricPanel> = {};
    const metricsLimit = 120;

    // Clear absent metrics from cache
    Object.keys(this.previewCache).forEach((metric) => {
      if (!nameSet.has(metric)) {
        delete this.previewCache[metric];
      }
    });

    for (let index = 0; index < sortedMetricNames.length; index++) {
      const metricName = sortedMetricNames[index];

      if (Object.keys(metricsMap).length > metricsLimit) {
        break;
      }

      const oldPanel = this.previewCache[metricName];

      metricsMap[metricName] = oldPanel || { name: metricName, index, loaded: false };
    }

    try {
      // If there is a current metric, do not present it
      const currentMetric = sceneGraph.getAncestor(this, MetricScene).state.metric;
      delete metricsMap[currentMetric];
    } catch (err) {
      // There is no current metric
    }

    this.previewCache = metricsMap;
    this.buildLayout();
  }

  private sortedPreviewMetrics() {
    return Object.values(this.previewCache).sort((a, b) => {
      if (a.isEmpty && b.isEmpty) {
        return a.index - b.index;
      }
      if (a.isEmpty) {
        return 1;
      }
      if (b.isEmpty) {
        return -1;
      }
      return a.index - b.index;
    });
  }

  private async buildLayout() {
    const trail = getTrailFor(this);
    const showPreviews = trail.state.showPreviews;
    // Temp hack when going back to select metric scene and variable updates
    if (this.ignoreNextUpdate) {
      this.ignoreNextUpdate = false;
      return;
    }

    const children: SceneFlexItem[] = [];

    const metricsList = this.sortedPreviewMetrics();

    // Get the current filters to determine the count of them
    // Which is required for `getPreviewPanelFor`
    const filters = getFilters(this);
    const currentFilterCount = filters?.length || 0;

    for (let index = 0; index < metricsList.length; index++) {
      const metric = metricsList[index];
      const metadata = await trail.getMetricMetadata(metric.name);
      const description = getMetricDescription(metadata);

      if (showPreviews) {
        if (metric.itemRef && metric.isPanel) {
          children.push(metric.itemRef.resolve());
          continue;
        }
        // refactor this into the query generator in future
        const isNative = trail.isNativeHistogram(metric.name);
        const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description, isNative, true);

        metric.itemRef = panel.getRef();
        metric.isPanel = true;
        children.push(panel);
      } else {
        const panel = new SceneCSSGridItem({
          $variables: new SceneVariableSet({
            variables: getVariablesWithMetricConstant(metric.name),
          }),
          body: getCardPanelFor(metric.name, description),
        });
        metric.itemRef = panel.getRef();
        metric.isPanel = false;
        children.push(panel);
      }
    }

    const rowTemplate = showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT;

    this.state.body.setState({ children, autoRows: rowTemplate });
  }

  public updateMetricPanel = (metric: string, isLoaded?: boolean, isEmpty?: boolean) => {
    const metricPanel = this.previewCache[metric];
    if (metricPanel) {
      metricPanel.isEmpty = isEmpty;
      metricPanel.loaded = isLoaded;
      this.previewCache[metric] = metricPanel;
      if (this.state.metricPrefix === 'All') {
        this.buildLayout();
      }
    }
  };

  public onSearchQueryChange = (evt: SyntheticEvent<HTMLInputElement>) => {
    const metricSearch = evt.currentTarget.value;
    const trail = getTrailFor(this);
    // Update the variable
    trail.setState({ metricSearch });
  };

  public onPrefixFilterChange = (val: SelectableValue) => {
    this.setState({ metricPrefix: val.value });
    this._refreshMetricNames();
  };

  public reportPrefixFilterInteraction = (isMenuOpen: boolean) => {
    const trail = getTrailFor(this);
    const { steps, currentStep } = trail.state.history.state;
    const previousMetric = steps[currentStep]?.trailState.metric;
    const isRelatedMetricSelector = previousMetric !== undefined;

    reportExploreMetrics('prefix_filter_clicked', {
      from: isRelatedMetricSelector ? 'related_metrics' : 'metric_list',
      action: isMenuOpen ? 'open' : 'close',
    });
  };

  public onToggleOtelExperience = () => {
    const trail = getTrailFor(this);
    const useOtelExperience = trail.state.useOtelExperience;
    // set the startButtonClicked to null as we have gone past the owrkflow this is needed for
    let startButtonClicked = false;
    let resettingOtel = true;
    if (useOtelExperience) {
      reportExploreMetrics('otel_experience_toggled', { value: 'off' });
      // if turning off OTel
      resettingOtel = false;
      trail.resetOtelExperience();
    } else {
      reportExploreMetrics('otel_experience_toggled', { value: 'on' });
    }
    setOtelExperienceToggleState(!useOtelExperience);
    trail.setState({ useOtelExperience: !useOtelExperience, resettingOtel, startButtonClicked });
  };

  public static Component = ({ model }: SceneComponentProps<MetricSelectScene>) => {
    const {
      body,
      metricNames,
      metricNamesError,
      metricNamesLoading,
      metricNamesWarning,
      rootGroup,
      metricPrefix,
      missingOtelTargets,
    } = model.useState();
    const { children } = body.useState();
    const trail = getTrailFor(model);
    const styles = useStyles2(getStyles);

    const [warningDismissed, dismissWarning] = useReducer(() => true, false);

    const { metricSearch, useOtelExperience, hasOtelResources, isStandardOtel, metric } = trail.useState();

    const tooStrict = children.length === 0 && metricSearch;
    const noMetrics = !metricNamesLoading && metricNames && metricNames.length === 0;

    const isLoading = metricNamesLoading && children.length === 0;

    const blockingMessage = isLoading
      ? undefined
      : missingOtelTargets
        ? 'There are no metrics found. Please adjust your filters based on your OTel resource attributes.'
        : (noMetrics && 'There are no results found. Try a different time range or a different data source.') ||
          (tooStrict && 'There are no results found. Try adjusting your search or filters.') ||
          undefined;

    const metricNamesWarningIcon = metricNamesWarning ? (
      <Tooltip
        content={
          <>
            <h4>Unable to retrieve metric names</h4>
            <p>{metricNamesWarning}</p>
          </>
        }
      >
        <Icon className={styles.warningIcon} name="exclamation-triangle" />
      </Tooltip>
    ) : undefined;

    return (
      <div className={styles.container}>
        <div className={styles.header}>
          <Field label={'Search metrics'} className={styles.searchField}>
            <Input
              placeholder="Search metrics"
              prefix={<Icon name={'search'} />}
              value={metricSearch}
              onChange={model.onSearchQueryChange}
              suffix={metricNamesWarningIcon}
            />
          </Field>
          <Field
            label={
              <div className={styles.displayOptionTooltip}>
                <Trans i18nKey="explore-metrics.viewBy">View by</Trans>
                <IconButton name={'info-circle'} size="sm" variant={'secondary'} tooltip={viewByTooltip} />
              </div>
            }
            className={styles.displayOption}
          >
            <Select
              value={metricPrefix}
              onChange={model.onPrefixFilterChange}
              onOpenMenu={() => model.reportPrefixFilterInteraction(true)}
              onCloseMenu={() => model.reportPrefixFilterInteraction(false)}
              options={[
                {
                  label: 'All metric names',
                  value: METRIC_PREFIX_ALL,
                },
                ...Array.from(rootGroup?.groups.keys() ?? []).map((g) => ({ label: `${g}_`, value: g })),
              ]}
              className="metrics-drilldown-metric-prefix-select"
            />
          </Field>
          {!metric && hasOtelResources && (
            <Field
              label={
                <>
                  <div className={styles.displayOptionTooltip}>
                    <Trans i18nKey="trails.metric-select.filter-by">Filter by</Trans>
                    <IconButton
                      name={'info-circle'}
                      size="sm"
                      variant={'secondary'}
                      tooltip={
                        <Trans i18nKey="trails.metric-select.otel-switch">
                          This switch enables filtering by OTel resources for OTel native data sources.
                        </Trans>
                      }
                    />
                    <div>
                      <Badge
                        text={<Trans i18nKey="trails.metric-select.new-badge">New</Trans>}
                        color={'blue'}
                        className={styles.badgeStyle}
                      ></Badge>
                    </div>
                  </div>
                </>
              }
              className={styles.displayOption}
            >
              <div
                title={
                  !isStandardOtel ? 'This setting is disabled because this is not an OTel native data source.' : ''
                }
              >
                <InlineSwitch
                  disabled={!isStandardOtel}
                  showLabel={true}
                  label="OTel experience"
                  value={useOtelExperience}
                  onChange={model.onToggleOtelExperience}
                />
              </div>
            </Field>
          )}
        </div>
        {metricNamesError && (
          <Alert title="Unable to retrieve metric names" severity="error">
            <div>We are unable to connect to your data source. Double check your data source URL and credentials.</div>
            <div>({metricNamesError})</div>
          </Alert>
        )}
        {metricNamesWarning && !warningDismissed && (
          <Alert
            title="Unable to retrieve all metric names"
            severity="warning"
            onSubmit={dismissWarning}
            onRemove={dismissWarning}
          >
            <div>{metricNamesWarning}</div>
          </Alert>
        )}
        <StatusWrapper {...{ isLoading, blockingMessage }}>
          {body instanceof SceneFlexLayout && <body.Component model={body} />}
          {body instanceof SceneCSSGridLayout && <body.Component model={body} />}
        </StatusWrapper>
      </div>
    );
  };
}

function getCardPanelFor(metric: string, description?: string) {
  return PanelBuilders.text()
    .setTitle(metric)
    .setDescription(description)
    .setHeaderActions([new SelectMetricAction({ metric, title: 'Select' })])
    .setOption('content', '')
    .build();
}

function getStyles(theme: GrafanaTheme2) {
  return {
    container: css({
      display: 'flex',
      flexDirection: 'column',
    }),
    headingWrapper: css({
      marginBottom: theme.spacing(0.5),
    }),
    header: css({
      flexGrow: 0,
      display: 'flex',
      gap: theme.spacing(2),
      marginBottom: theme.spacing(2),
      alignItems: 'flex-end',
    }),
    searchField: css({
      flexGrow: 1,
      marginBottom: 0,
    }),
    metricTabGroup: css({
      marginBottom: theme.spacing(2),
    }),
    displayOption: css({
      flexGrow: 0,
      marginBottom: 0,
      minWidth: '184px',
    }),
    displayOptionTooltip: css({
      display: 'flex',
      gap: theme.spacing(1),
    }),
    warningIcon: css({
      color: theme.colors.warning.main,
    }),
    badgeStyle: css({
      display: 'flex',
      height: '1rem',
      padding: '0rem 0.25rem 0 0.30rem',
      alignItems: 'center',
      borderRadius: theme.shape.radius.pill,
      border: `1px solid ${theme.colors.info.text}`,
      background: theme.colors.info.transparent,
      marginTop: '4px',
      marginLeft: '-3px',
    }),
  };
}

function getMetricSearch(scene: SceneObject) {
  const trail = getTrailFor(scene);
  return trail.state.metricSearch || '';
}
