import { css } from '@emotion/css';
import { autoUpdate, flip, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react';
import debounce from 'debounce-promise';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import * as React from 'react';

import { type GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Alert, floatingUtils, Icon, Input, LoadingBar, Stack, Text, useStyles2 } from '@grafana/ui';
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { getStatusFromError } from 'app/core/utils/errors';
import { type DashboardViewItemWithUIItems, type DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { TEAM_FOLDERS_UID } from 'app/features/search/constants';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { type QueryResponse } from 'app/features/search/service/types';
import { queryResultToViewItem } from 'app/features/search/service/utils';
import { type DashboardViewItem } from 'app/features/search/types';
import { type PermissionLevel } from 'app/types/acl';

import { FolderRepo } from './FolderRepo';
import { getDOMId, NestedFolderList } from './NestedFolderList';
import Trigger from './Trigger';
import { useFoldersQuery } from './useFoldersQuery';
import { useGetTeamFolders } from './useTeamOwnedFolder';
import { useTreeInteractions } from './useTreeInteractions';
import { getRootFolderItem } from './utils';

export interface NestedFolderPickerProps {
  /* Folder UID to show as selected */
  value?: string;

  /** Show an invalid state around the folder picker */
  invalid?: boolean;

  /* Whether to show the root 'Dashboards' (formally General) folder as selectable */
  showRootFolder?: boolean;

  /* Folder UIDs to exclude from the picker, to prevent invalid operations */
  excludeUIDs?: string[];

  /* Start tree from this folder instead of root. When set, the picker is scoped
   to this subtree — only descendants of this folder are shown, and top-level
   items like team folders are excluded. */
  rootFolderUID?: string;

  /* Custom root folder item, default is "Dashboards" */
  rootFolderItem?: DashboardsTreeItem;

  /* Show folders matching this permission, mainly used to also show folders user can view. Defaults to showing only folders user has Edit  */
  permission?: 'view' | 'edit';

  /* Callback for when the user selects a folder */
  onChange?: (folderUID: string | undefined, folderName: string | undefined) => void;

  /* Whether the picker should be clearable */
  clearable?: boolean;

  /* HTML ID for the button element for form labels */
  id?: string;
}

const debouncedSearch = debounce(getSearchResults, 300);

async function getSearchResults(searchQuery: string, permission?: PermissionLevel) {
  const queryResponse = await getGrafanaSearcher().search({
    query: searchQuery,
    kind: ['folder'],
    limit: 100,
    permission,
  });

  const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view));
  return { ...queryResponse, items };
}

export function NestedFolderPicker({
  value,
  invalid,
  showRootFolder = true,
  clearable = false,
  excludeUIDs,
  rootFolderUID,
  rootFolderItem,
  permission = 'edit',
  onChange,
  id,
}: NestedFolderPickerProps) {
  const styles = useStyles2(getStyles);
  const getSelectedFolderResult = useGetFolderQueryFacade(value);

  // user might not have access to the folder, but they have access to the dashboard
  // in this case we disable the folder picker - this is an edge case when user has edit access to a dashboard
  // but doesn't have access to the folder
  const isForbidden = getStatusFromError(getSelectedFolderResult.error) === 403;

  const [search, setSearch] = useState('');
  const [searchResults, setSearchResults] = useState<(QueryResponse & { items: DashboardViewItem[] }) | null>(null);
  const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false);

  const [autoFocusButton, setAutoFocusButton] = useState(false);
  const [overlayOpen, setOverlayOpen] = useState(false);
  // keep Team folders expanded by default so the UX matches when team folders were previously listed at top level
  const [foldersOpenState, setFoldersOpenState] = useState<Record<string, boolean>>({ [TEAM_FOLDERS_UID]: true });
  const overlayId = useId();

  const lastSearchTimestamp = useRef<number>(0);

  const {
    teamFolderTreeItems,
    teamFolderOwnersByUid,
    error: teamFoldersError,
  } = useTeamFolders(foldersOpenState, value, onChange);

  const isBrowsing = Boolean(overlayOpen && !(search && searchResults));
  const {
    emptyFolders,
    items: browseFlatTree,
    isLoading: isBrowseLoading,
    error: browseError,
    requestNextPage: fetchFolderPage,
  } = useFoldersQuery({
    isBrowsing,
    openFolders: foldersOpenState,
    permission,
    rootFolderUID,
    rootFolderItem,
  });

  useEffect(() => {
    if (!search) {
      setSearchResults(null);
      return;
    }

    const timestamp = Date.now();
    setIsFetchingSearchResults(true);

    debouncedSearch(search, permission).then((queryResponse) => {
      // Only keep the results if it's was issued after the most recently resolved search.
      // This prevents results showing out of order if first request is slower than later ones.
      // We don't need to worry about clearing the isFetching state either - if there's a later
      // request in progress, this will clear it for us
      if (timestamp > lastSearchTimestamp.current) {
        const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view));
        setSearchResults({ ...queryResponse, items });
        setIsFetchingSearchResults(false);
        lastSearchTimestamp.current = timestamp;
      }
    });
  }, [search, permission]);

  // the order of middleware is important!
  const middleware = [
    flip({
      // see https://floating-ui.com/docs/flip#combining-with-shift
      crossAxis: false,
      boundary: document.getElementById(floatingUtils.BOUNDARY_ELEMENT_ID) ?? undefined,
    }),
  ];

  const { context, refs, floatingStyles, elements } = useFloating({
    open: overlayOpen,
    placement: 'bottom',
    onOpenChange: (value) => {
      // ensure state is clean on opening the overlay
      if (value) {
        setSearch('');
        setAutoFocusButton(true);
      }
      setOverlayOpen(value);
    },
    middleware,
    whileElementsMounted: autoUpdate,
  });

  const click = useClick(context);
  const dismiss = useDismiss(context);

  const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]);

  const handleFolderExpand = useCallback(
    async (uid: string, newOpenState: boolean) => {
      setFoldersOpenState((old) => ({ ...old, [uid]: newOpenState }));

      // Team folders is a virtual folder, so don't trigger browse pagination for it
      if (uid === TEAM_FOLDERS_UID) {
        return;
      }

      if (newOpenState && !foldersOpenState[uid]) {
        fetchFolderPage(uid);
      }
    },
    [fetchFolderPage, foldersOpenState]
  );

  const handleFolderSelect = useCallback(
    (item: DashboardViewItem) => {
      if (onChange) {
        onChange(item.uid, item.title);
      }
      setOverlayOpen(false);
    },
    [onChange]
  );

  const handleClearSelection = useCallback(
    (event: React.MouseEvent<SVGElement> | React.KeyboardEvent<SVGElement>) => {
      event.preventDefault();
      event.stopPropagation();
      if (onChange) {
        onChange(undefined, undefined);
      }
    },
    [onChange]
  );

  const handleCloseOverlay = useCallback(() => setOverlayOpen(false), [setOverlayOpen]);

  const handleLoadMore = useCallback(
    (folderUID: string | undefined) => {
      if (search) {
        return;
      }

      fetchFolderPage(folderUID);
    },
    [search, fetchFolderPage]
  );

  const flatTree = useMemo(() => {
    let flatTree: DashboardsTreeItem[];

    if (isBrowsing) {
      flatTree = browseFlatTree;

      // Theoretically this and excluded items could be done in a single iteration, but as these are used infrequently,
      // it does not seem worth the tradeoff of readability.
      if (!showRootFolder) {
        flatTree = filterRootItem(flatTree);
      }

      // Only show team folders when browsing the full tree (no rootFolderUID scope)
      const fullTree = rootFolderUID ? flatTree : [...teamFolderTreeItems, ...flatTree];
      // Add "Team folders" at the top of the tree list.
      return filterExcludedItems(fullTree, excludeUIDs);
    } else {
      flatTree = searchResultsToTreeItems(searchResults?.items || []);
      return filterExcludedItems(flatTree, excludeUIDs);
    }
  }, [
    browseFlatTree,
    excludeUIDs,
    isBrowsing,
    searchResults?.items,
    showRootFolder,
    teamFolderTreeItems,
    rootFolderUID,
  ]);

  const isItemLoaded = useCallback(
    (itemIndex: number) => {
      const treeItem = flatTree[itemIndex];
      if (!treeItem) {
        return false;
      }

      const item = treeItem.item;
      const result = !(item.kind === 'ui' && item.uiKind === 'pagination-placeholder');

      return result;
    },
    [flatTree]
  );

  const isLoading = isBrowseLoading || isFetchingSearchResults;
  const displayError = teamFoldersError || browseError;

  const { focusedItemIndex, handleKeyDown } = useTreeInteractions({
    tree: flatTree,
    handleCloseOverlay,
    handleFolderSelect,
    handleFolderExpand,
    idPrefix: overlayId,
    search,
    visible: overlayOpen,
  });

  let label = getSelectedFolderResult.data?.title;
  if (value === '') {
    label = t('browse-dashboards.folder-picker.root-title', 'Dashboards');
  }

  // Display the folder name and provisioning status when the picker is closed
  const labelComponent = label ? (
    <Stack alignItems={'center'}>
      <Text truncate>{label}</Text>
      <FolderRepo folder={getSelectedFolderResult.data} />
    </Stack>
  ) : (
    ''
  );

  if (!overlayOpen) {
    return (
      <Trigger
        id={id}
        label={labelComponent}
        handleClearSelection={clearable && value !== undefined ? handleClearSelection : undefined}
        invalid={invalid}
        isLoading={getSelectedFolderResult.isLoading}
        autoFocus={autoFocusButton}
        ref={refs.setReference}
        aria-label={
          label
            ? t('browse-dashboards.folder-picker.accessible-label', 'Select folder: {{ label }} currently selected', {
                label,
              })
            : undefined
        }
        {...getReferenceProps()}
        disabled={isForbidden}
      />
    );
  }

  return (
    <>
      <Input
        ref={refs.setReference}
        autoFocus
        prefix={label ? <Icon name="folder" /> : <Icon name="search" />}
        placeholder={label ?? t('browse-dashboards.folder-picker.search-placeholder', 'Search folders')}
        value={search}
        invalid={invalid}
        className={styles.search}
        onChange={(e) => setSearch(e.currentTarget.value)}
        aria-autocomplete="list"
        aria-expanded
        aria-haspopup
        aria-controls={overlayId}
        aria-owns={overlayId}
        aria-activedescendant={getDOMId(overlayId, flatTree[focusedItemIndex]?.item.uid)}
        role="combobox"
        {...getReferenceProps()}
        onKeyDown={handleKeyDown}
      />
      <fieldset
        ref={refs.setFloating}
        id={overlayId}
        className={styles.tableWrapper}
        style={{
          ...floatingStyles,
          width: elements.domReference?.clientWidth,
        }}
        {...getFloatingProps()}
      >
        {displayError && (
          <Alert
            className={styles.error}
            severity="warning"
            title={
              teamFoldersError
                ? t('browse-dashboards.folder-picker.team-folders-error-title', 'Error loading team folders')
                : t('browse-dashboards.folder-picker.error-title', 'Error loading some folders')
            }
          >
            {displayError.message ||
              displayError.toString?.() ||
              t('browse-dashboards.folder-picker.unknown-error', 'Unknown error')}
          </Alert>
        )}
        {isLoading && (
          <div className={styles.loader}>
            <LoadingBar width={600} />
          </div>
        )}

        <NestedFolderList
          items={flatTree}
          selectedFolder={value}
          focusedItemIndex={focusedItemIndex}
          onFolderExpand={handleFolderExpand}
          onFolderSelect={handleFolderSelect}
          idPrefix={overlayId}
          foldersAreOpenable={!(search && searchResults)}
          isItemLoaded={isItemLoaded}
          requestLoadMore={handleLoadMore}
          emptyFolders={emptyFolders}
          teamFolderOwnersByUid={teamFolderOwnersByUid}
        />
      </fieldset>
    </>
  );
}

function useTeamFolders(
  foldersOpenState: Record<string, boolean>,
  value?: string,
  onChange?: (folderUID: string | undefined, folderName: string | undefined) => void
) {
  const { foldersByTeam, error } = useGetTeamFolders({ skip: !config.featureToggles.teamFolders });
  const teamFolders = useMemo(() => foldersByTeam.flatMap(({ folders }) => folders), [foldersByTeam]);
  const firstTeamFolder = teamFolders[0];

  const teamFolderOwnersByUid = useMemo(() => {
    return foldersByTeam.reduce<Record<string, { name: string; avatarUrl?: string }>>((acc, { team, folders }) => {
      for (const f of folders) {
        acc[f.name] = { name: team.name, avatarUrl: team.avatarUrl };
      }
      return acc;
    }, {});
  }, [foldersByTeam]);

  const teamFolderTreeItems = useMemo(() => {
    if (!foldersByTeam || foldersByTeam.length === 0) {
      return [];
    }
    // "Team folders" is a virtual root, sibling to the "Dashboards" virtual root.
    const baseLevel = 0;
    const childLevel = 1;
    const teamFoldersIsOpen = foldersOpenState[TEAM_FOLDERS_UID] ?? true;

    const parentItem: DashboardsTreeItem<DashboardViewItemWithUIItems> = {
      isOpen: teamFoldersIsOpen,
      level: baseLevel,
      disabled: true,
      item: {
        kind: 'folder' as const,
        title: t('browse-dashboards.folder-picker.team-folders', 'Team folders'),
        uid: TEAM_FOLDERS_UID,
        parentUID: undefined,
      },
    };

    const children = foldersByTeam.flatMap(({ folders }) => {
      return folders.map((folder) => ({
        isOpen: false,
        level: childLevel,
        parentUID: TEAM_FOLDERS_UID,
        item: {
          kind: 'folder' as const,
          title: folder.title,
          uid: folder.name,
          parentUID: TEAM_FOLDERS_UID,
        },
      }));
    });

    return teamFoldersIsOpen ? [parentItem, ...children] : [parentItem];
  }, [foldersByTeam, foldersOpenState]);

  const preselectDidRun = useRef(false);
  useEffect(() => {
    if (value === '' && firstTeamFolder && onChange && !preselectDidRun.current) {
      preselectDidRun.current = true;
      onChange(firstTeamFolder.name, firstTeamFolder.title);
    }
  }, [value, firstTeamFolder, onChange]);

  return {
    teamFolderTreeItems,
    teamFolderOwnersByUid,
    error,
  };
}

function searchResultsToTreeItems(items: DashboardViewItem[]): DashboardsTreeItem[] {
  return (
    items.map((item) => ({
      isOpen: false,
      level: 0,
      item: {
        kind: 'folder' as const,
        title: item.title,
        uid: item.uid,
        parentUID: item.parentUID,
        parentTitle: item.parentTitle,
      },
    })) ?? []
  );
}

function filterRootItem(items: DashboardsTreeItem[]) {
  const rootUid = getRootFolderItem().item.uid;
  const hasRootItem = items.some((item) => item.item.uid === rootUid);
  if (!hasRootItem) {
    return items;
  }

  const itemsFiltered: DashboardsTreeItem[] = [];
  for (const item of items) {
    // We remove the root item and also adjust the level of all items that should have been under it.
    if (item.item.uid !== rootUid) {
      itemsFiltered.push({
        ...item,
        level: item.level - 1,
      });
    }
  }
  return itemsFiltered;
}

function filterExcludedItems(items: DashboardsTreeItem[], excludeUIDs: string[] | undefined) {
  if (excludeUIDs?.length) {
    return items.filter((i) => !excludeUIDs?.includes(i.item.uid));
  }
  return items;
}

const getStyles = (theme: GrafanaTheme2) => {
  return {
    button: css({
      maxWidth: '100%',
    }),
    error: css({
      marginBottom: 0,
    }),
    tableWrapper: css({
      boxShadow: theme.shadows.z3,
      position: 'relative',
      zIndex: theme.zIndex.portal,
    }),
    loader: css({
      position: 'absolute',
      top: 0,
      left: 0,
      right: 0,
      zIndex: theme.zIndex.portal + 1,
      overflow: 'hidden', // loading bar overflows its container, so we need to clip it
    }),
    search: css({
      input: {
        cursor: 'default',
      },
    }),
  };
};
