import { css } from '@emotion/css';
import { Resizable, type ResizeCallback } from 're-resizable';
import { useCallback, useEffect, useMemo, useState } from 'react';

import {
  type AbsoluteTimeRange,
  type DataFrame,
  type ExploreLogsPanelState,
  type GrafanaTheme2,
  type Labels,
  type LogRowModel,
  type LogsSortOrder,
  type SelectableValue,
  type SplitOpen,
  store,
  type TimeRange,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui';
import { FIELD_SELECTOR_MIN_WIDTH } from 'app/features/logs/components/fieldSelector/FieldSelector';
import { LogsTableFieldSelector } from 'app/features/logs/components/fieldSelector/LogsTableFieldSelector';
import { getFieldSelectorWidth } from 'app/features/logs/components/fieldSelector/fieldSelectorUtils';
import { getSuggestedFieldsFromTable } from 'app/features/logs/components/fieldSelector/getSuggestedFieldsFromTable';
import { reportInteractionOnce } from 'app/features/logs/components/panel/analytics';
import { parseLogsFrame } from 'app/features/logs/logsFrame';

import { LogsTable } from './LogsTable';
import { SETTING_KEY_ROOT } from './utils/logs';

interface Props {
  logsFrames: DataFrame[];
  width: number;
  timeZone: string;
  splitOpen: SplitOpen;
  range: TimeRange;
  logsSortOrder: LogsSortOrder;
  panelState: ExploreLogsPanelState | undefined;
  updatePanelState: (panelState: Partial<ExploreLogsPanelState>) => void;
  onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void;
  onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void;
  datasourceType?: string;
  exploreId?: string;
  displayedFields?: string[];
  absoluteRange?: AbsoluteTimeRange;
  logRows?: LogRowModel[];
}

type ActiveFieldMeta = {
  active: false;
  index: undefined; // if undefined the column is not selected
};

type InactiveFieldMeta = {
  active: true;
  index: number; // if undefined the column is not selected
};

type GenericMeta = {
  percentOfLinesWithLabel: number;
  type?: 'BODY_FIELD' | 'TIME_FIELD';
};

export type FieldNameMeta = (InactiveFieldMeta | ActiveFieldMeta) & GenericMeta;

type FieldName = string;
export type FieldNameMetaStore = Record<FieldName, FieldNameMeta>;

export function LogsTableWrap(props: Props) {
  const { logsFrames, updatePanelState, panelState } = props;
  const propsColumns = panelState?.columns;
  // Save the normalized cardinality of each label
  const [columnsWithMeta, setColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
  const dragStyles = useStyles2(getDragStyles);

  // Filtered copy of columnsWithMeta that only includes matching results
  const [filteredColumnsWithMeta, setFilteredColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);

  const height = getLogsTableHeight();
  const panelStateRefId = props?.panelState?.refId;

  // The current dataFrame containing the refId of the current query
  const [currentDataFrame, setCurrentDataFrame] = useState<DataFrame>(
    logsFrames.find((f) => f.refId === panelStateRefId) ?? logsFrames[0]
  );

  const getColumnsFromProps = useCallback(
    (fieldNames: FieldNameMetaStore) => {
      const previouslySelected = props.panelState?.columns;
      if (previouslySelected) {
        Object.values(previouslySelected).forEach((key, index) => {
          if (fieldNames[key]) {
            fieldNames[key].active = true;
            fieldNames[key].index = index;
          }
        });
      }
      return fieldNames;
    },
    [props.panelState?.columns]
  );

  const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);

  useEffect(() => {
    if (logsFrame?.timeField.name && logsFrame?.bodyField.name && !propsColumns) {
      const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
      updatePanelState({
        columns: Object.values(defaultColumns),
        visualisationType: 'table',
        labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
      });
    }
  }, [logsFrame, propsColumns, updatePanelState]);

  /**
   * When logs frame updates (e.g. query|range changes), we need to set the selected frame to state
   */
  useEffect(() => {
    const newFrame = logsFrames.find((f) => f.refId === panelStateRefId) ?? logsFrames[0];
    if (newFrame) {
      setCurrentDataFrame(newFrame);
    }
  }, [logsFrames, panelStateRefId]);

  /**
   * Keeps the filteredColumnsWithMeta state in sync with the columnsWithMeta state,
   * which can be updated by explore browser history state changes
   * This prevents an edge case bug where the user is navigating while a search is open.
   */
  useEffect(() => {
    if (!columnsWithMeta || !filteredColumnsWithMeta) {
      return;
    }
    let newFiltered = { ...filteredColumnsWithMeta };
    let flag = false;
    Object.keys(columnsWithMeta).forEach((key) => {
      if (newFiltered[key] && newFiltered[key].active !== columnsWithMeta[key].active) {
        newFiltered[key] = columnsWithMeta[key];
        flag = true;
      }
    });
    if (flag) {
      setFilteredColumnsWithMeta(newFiltered);
    }
  }, [columnsWithMeta, filteredColumnsWithMeta]);

  /**
   * when the query results change, we need to update the columnsWithMeta state
   * and reset any local search state
   *
   * This will also find all the unique labels, and calculate how many log lines have each label into the labelCardinality Map
   * Then it normalizes the counts
   *
   */
  useEffect(() => {
    // If the data frame is empty, there's nothing to viz, it could mean the user has unselected all columns
    if (!currentDataFrame.length) {
      return;
    }
    const numberOfLogLines = currentDataFrame ? currentDataFrame.length : 0;
    const logsFrame = parseLogsFrame(currentDataFrame);
    const labels = logsFrame?.getLogFrameLabelsAsLabels();

    const otherFields = [];

    if (logsFrame) {
      otherFields.push(...logsFrame.extraFields.filter((field) => !field?.config?.custom?.hidden));
    }
    if (logsFrame?.severityField) {
      otherFields.push(logsFrame?.severityField);
    }
    if (logsFrame?.bodyField) {
      otherFields.push(logsFrame?.bodyField);
    }
    if (logsFrame?.timeField) {
      otherFields.push(logsFrame?.timeField);
    }

    // Use a map to dedupe labels and count their occurrences in the logs
    const labelCardinality = new Map<FieldName, FieldNameMeta>();

    // What the label state will look like
    let pendingLabelState: FieldNameMetaStore = {};

    // If we have labels and log lines
    if (labels?.length && numberOfLogLines) {
      // Iterate through all of Labels
      labels.forEach((labels: Labels) => {
        const labelsArray = Object.keys(labels);
        // Iterate through the label values
        labelsArray.forEach((label) => {
          // If it's already in our map, increment the count
          if (labelCardinality.has(label)) {
            const value = labelCardinality.get(label);
            if (value) {
              if (value?.active) {
                labelCardinality.set(label, {
                  percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
                  active: true,
                  index: value.index,
                });
              } else {
                labelCardinality.set(label, {
                  percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
                  active: false,
                  index: undefined,
                });
              }
            }
            // Otherwise add it
          } else {
            labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: false, index: undefined });
          }
        });
      });

      // Converting the map to an object
      pendingLabelState = Object.fromEntries(labelCardinality);

      // Convert count to percent of log lines
      Object.keys(pendingLabelState).forEach((key) => {
        pendingLabelState[key].percentOfLinesWithLabel = normalize(
          pendingLabelState[key].percentOfLinesWithLabel,
          numberOfLogLines
        );
      });
    }

    // Normalize the other fields
    otherFields.forEach((field) => {
      const isActive = pendingLabelState[field.name]?.active;
      const index = pendingLabelState[field.name]?.index;
      if (isActive && index !== undefined) {
        pendingLabelState[field.name] = {
          percentOfLinesWithLabel: normalize(
            field.values.filter((value) => value !== null && value !== undefined).length,
            numberOfLogLines
          ),
          active: true,
          index: index,
        };
      } else {
        pendingLabelState[field.name] = {
          percentOfLinesWithLabel: normalize(
            field.values.filter((value) => value !== null && value !== undefined).length,
            numberOfLogLines
          ),
          active: false,
          index: undefined,
        };
      }
    });

    pendingLabelState = getColumnsFromProps(pendingLabelState);

    // Get all active columns
    const active = Object.keys(pendingLabelState).filter((key) => pendingLabelState[key].active);

    // If nothing is selected, then select the default columns
    if (active.length === 0) {
      if (logsFrame?.bodyField?.name) {
        pendingLabelState[logsFrame.bodyField.name].active = true;
      }
      if (logsFrame?.timeField?.name) {
        pendingLabelState[logsFrame.timeField.name].active = true;
      }
    }

    if (logsFrame?.bodyField?.name && logsFrame?.timeField?.name) {
      pendingLabelState[logsFrame.bodyField.name].type = 'BODY_FIELD';
      pendingLabelState[logsFrame.timeField.name].type = 'TIME_FIELD';
    }

    setColumnsWithMeta(pendingLabelState);

    // The panel state is updated when the user interacts with the multi-select sidebar
  }, [currentDataFrame, getColumnsFromProps]);

  const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(SETTING_KEY_ROOT));
  const tableWidth = props.width - sidebarWidth;

  const styles = useStyles2(getStyles, height, sidebarWidth);

  const onSortByChange = useCallback(
    (sortBy: Array<{ displayName: string; desc?: boolean }>) => {
      // Transform from Table format to URL format - only store the first sort column
      if (sortBy.length > 0) {
        // Defer the Redux dispatch to avoid updating ExploreActions during Table's render cycle
        // Even though this is called from an event handler, the synchronous Redux dispatch
        // can cause ExploreActions (which subscribes to panes state) to re-render while
        // Table is still rendering, triggering the React warning
        setTimeout(() => {
          updatePanelState({
            tableSortBy: sortBy[0].displayName,
            tableSortDir: sortBy[0].desc ? 'desc' : 'asc',
          });
        }, 0);
      }
    },
    [updatePanelState]
  );

  if (!columnsWithMeta) {
    return null;
  }

  function columnFilterEvent(columnName: string) {
    if (columnsWithMeta) {
      const newState = !columnsWithMeta[columnName]?.active;
      const priorActiveCount = Object.keys(columnsWithMeta).filter((column) => columnsWithMeta[column]?.active)?.length;
      const event = {
        columnAction: newState ? 'add' : 'remove',
        columnCount: newState ? priorActiveCount + 1 : priorActiveCount - 1,
        datasourceType: props.datasourceType,
      };
      reportInteraction('grafana_explore_logs_table_column_filter_clicked', event);
    }
  }

  const clearSelection = () => {
    const pendingLabelState = { ...columnsWithMeta };
    Object.keys(pendingLabelState).forEach((key) => {
      const isDefaultField = !!pendingLabelState[key].type;
      // after reset the only active fields are the special time and body fields
      pendingLabelState[key].active = isDefaultField ? true : false;
      // reset the index
      if (pendingLabelState[key].type === 'TIME_FIELD') {
        pendingLabelState[key].index = 0;
      } else {
        pendingLabelState[key].index = pendingLabelState[key].type === 'BODY_FIELD' ? 1 : undefined;
      }
    });
    setColumnsWithMeta(pendingLabelState);
  };

  const reorderColumn = (newColumns: string[]) => {
    const pendingLabelState = { ...columnsWithMeta };

    newColumns.forEach((key, index) => {
      pendingLabelState[key].index = index;
    });

    // Set local state
    setColumnsWithMeta(pendingLabelState);

    // Sync the explore state
    updateExploreState(pendingLabelState);
  };

  function updateExploreState(pendingLabelState: FieldNameMetaStore) {
    // Get all active columns and sort by index
    const newColumnsArray = Object.keys(pendingLabelState)
      // Only include active filters
      .filter((key) => pendingLabelState[key]?.active)
      .sort((a, b) => {
        const pa = pendingLabelState[a];
        const pb = pendingLabelState[b];
        if (pa.index !== undefined && pb.index !== undefined) {
          return pa.index - pb.index; // sort by index
        }
        return 0;
      });

    const newColumns: Record<number, string> = Object.assign(
      {},
      // Get the keys of the object as an array
      newColumnsArray
    );

    const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
    const newPanelState: ExploreLogsPanelState = {
      ...props.panelState,
      // URL format requires our array of values be an object, so we convert it using object.assign
      columns: Object.keys(newColumns).length ? newColumns : defaultColumns,
      refId: currentDataFrame.refId,
      visualisationType: 'table',
      labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
    };

    // Update url state
    updatePanelState(newPanelState);
  }

  // Toggle a column on or off when the user interacts with an element in the multi-select sidebar
  const toggleColumn = (columnName: FieldName) => {
    if (!columnsWithMeta || !(columnName in columnsWithMeta)) {
      console.warn('failed to get column', columnsWithMeta);
      return;
    }

    const length = Object.keys(columnsWithMeta).filter((c) => columnsWithMeta[c].active).length;
    const isActive = !columnsWithMeta[columnName].active ? true : undefined;

    let pendingLabelState: FieldNameMetaStore;
    if (isActive) {
      pendingLabelState = {
        ...columnsWithMeta,
        [columnName]: {
          ...columnsWithMeta[columnName],
          active: isActive,
          index: length,
        },
      };
    } else {
      pendingLabelState = {
        ...columnsWithMeta,
        [columnName]: {
          ...columnsWithMeta[columnName],
          active: false,
          index: undefined,
        },
      };
    }

    // Analytics
    columnFilterEvent(columnName);

    // Set local state
    setColumnsWithMeta(pendingLabelState);

    // If user is currently filtering, update filtered state
    if (filteredColumnsWithMeta) {
      const active = !filteredColumnsWithMeta[columnName]?.active;
      let pendingFilteredLabelState: FieldNameMetaStore;
      if (active) {
        pendingFilteredLabelState = {
          ...filteredColumnsWithMeta,
          [columnName]: {
            ...filteredColumnsWithMeta[columnName],
            active: active,
            index: length,
          },
        };
      } else {
        pendingFilteredLabelState = {
          ...filteredColumnsWithMeta,
          [columnName]: {
            ...filteredColumnsWithMeta[columnName],
            active: false,
            index: undefined,
          },
        };
      }

      setFilteredColumnsWithMeta(pendingFilteredLabelState);
    }

    updateExploreState(pendingLabelState);
  };

  const onFrameSelectorChange = (value: SelectableValue<string>) => {
    const matchingDataFrame = logsFrames.find((frame) => frame.refId === value.value);
    if (matchingDataFrame) {
      setCurrentDataFrame(logsFrames.find((frame) => frame.refId === value.value) ?? logsFrames[0]);
    }
    props.updatePanelState({ refId: value.value, labelFieldName: logsFrame?.getLabelFieldName() ?? undefined });
  };

  const getOnResize: ResizeCallback = (event, direction, ref) => {
    const newSidebarWidth = Number(ref.style.width.slice(0, -2));
    if (!isNaN(newSidebarWidth)) {
      setSidebarWidth(newSidebarWidth);
      store.set(`${SETTING_KEY_ROOT}.fieldSelector.width`, newSidebarWidth);
      reportInteractionOnce('logs_field_selector_resized', {
        mode: 'table',
      });
    }
  };

  return (
    <>
      <div>
        {logsFrames.length > 1 && (
          <div>
            <InlineField
              label={t('explore.logs-table-wrap.label-select-query', 'Select query')}
              htmlFor="explore_logs_table_frame_selector"
              labelWidth={22}
              tooltip={t(
                'explore.logs-table-wrap.tooltip-select-query-visualize-table',
                'Select a query to visualize in the table'
              )}
            >
              <Select
                inputId={'explore_logs_table_frame_selector'}
                aria-label={t('explore.logs-table-wrap.aria-label-select-query-by-name', 'Select query by name')}
                value={currentDataFrame.refId}
                options={logsFrames.map((frame) => {
                  return {
                    label: frame.refId,
                    value: frame.refId,
                  };
                })}
                onChange={onFrameSelectorChange}
              />
            </InlineField>
          </div>
        )}
      </div>
      <div className={styles.wrapper}>
        <Resizable
          enable={{
            right: true,
          }}
          handleClasses={{ right: dragStyles.dragHandleVertical }}
          size={{ width: sidebarWidth, height: getLogsTableHeight() }}
          defaultSize={{ width: sidebarWidth, height: getLogsTableHeight() }}
          minWidth={FIELD_SELECTOR_MIN_WIDTH}
          maxWidth={props.width * 0.8}
          onResize={getOnResize}
        >
          <LogsTableFieldSelector
            getSuggestedFields={getSuggestedFieldsFromTable}
            clear={clearSelection}
            columnsWithMeta={columnsWithMeta}
            dataFrames={[currentDataFrame]}
            reorder={reorderColumn}
            setWidth={setSidebarWidth}
            width={sidebarWidth}
            toggle={toggleColumn}
          />
        </Resizable>
        <div className={styles.tableContainer}>
          <div className={styles.tableWrapper}>
            <LogsTable
              logsFrame={logsFrame}
              onClickFilterLabel={props.onClickFilterLabel}
              onClickFilterOutLabel={props.onClickFilterOutLabel}
              logsSortOrder={props.logsSortOrder}
              range={props.range}
              splitOpen={props.splitOpen}
              timeZone={props.timeZone}
              width={tableWidth}
              dataFrame={currentDataFrame}
              columnsWithMeta={columnsWithMeta}
              height={height}
              tableSortBy={panelState?.tableSortBy}
              tableSortDir={panelState?.tableSortDir}
              onSortByChange={onSortByChange}
              displayedFields={props.displayedFields}
              exploreId={props.exploreId}
              absoluteRange={props.absoluteRange}
              logRows={props.logRows}
            />
          </div>
        </div>
      </div>
    </>
  );
}

const normalize = (value: number, total: number): number => {
  return Math.ceil((100 * value) / total);
};

function getStyles(theme: GrafanaTheme2, height: number, width: number) {
  return {
    wrapper: css({
      display: 'flex',
    }),
    sidebar: css({
      height: height,
      fontSize: theme.typography.pxToRem(11),
      overflowY: 'hidden',
      width: width,
      paddingRight: theme.spacing(3),
    }),
    tableContainer: css({
      position: 'relative',
      overflow: 'hidden',
      flex: 1,
    }),
    tableWrapper: css({
      position: 'absolute',
      left: 0,
      top: 0,
    }),
  };
}

export const getLogsTableHeight = () => {
  // Instead of making the height of the table based on the content (like in the table panel itself), let's try to use the vertical space that is available.
  // Since this table is in explore, we can expect the user to be running multiple queries that return disparate numbers of rows and labels in the same session
  // Also changing the height of the table between queries can be and cause content to jump, so we'll set a minimum height of 500px, and a max based on the innerHeight
  // Ideally the table container should always be able to fit in the users viewport without needing to scroll
  return Math.max(window.innerHeight - 500, 500);
};
