import { isEmpty } from 'lodash';

import { DataFrame, DataFrameView, getDisplayProcessor, SelectableValue, toDataFrame } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import kbn from 'app/core/utils/kbn';

import { getAPINamespace } from '../../../api/utils';

import {
  DashboardQueryResult,
  GrafanaSearcher,
  LocationInfo,
  QueryResponse,
  SearchQuery,
  SearchResultMeta,
} from './types';
import { replaceCurrentFolderQuery } from './utils';

// The backend returns an empty frame with a special name to indicate that the indexing engine is being rebuilt,
// and that it can not serve any search requests. We are temporarily using the old SQL Search API as a fallback when that happens.
const loadingFrameName = 'Loading';

const searchURI = `apis/dashboard.grafana.app/v0alpha1/namespaces/${getAPINamespace()}/search`;

export type SearchHit = {
  resource: string; // dashboards | folders
  name: string;
  title: string;
  location: string;
  folder: string;
  tags: string[];

  field: Record<string, string | number>; // extra fields from the backend - sort fields included here as well

  // calculated in the frontend
  url: string;
};

export type SearchAPIResponse = {
  totalHits: number;
  hits: SearchHit[];
  facets?: {
    tags?: {
      terms?: Array<{
        term: string;
        count: number;
      }>;
    };
  };
};

const folderViewSort = 'name_sort';

export class UnifiedSearcher implements GrafanaSearcher {
  locationInfo: Promise<Record<string, LocationInfo>>;

  constructor(private fallbackSearcher: GrafanaSearcher) {
    this.locationInfo = loadLocationInfo();
  }

  async search(query: SearchQuery): Promise<QueryResponse> {
    if (query.facet?.length) {
      throw new Error('facets not supported!');
    }
    return this.doSearchQuery(query);
  }

  async starred(query: SearchQuery): Promise<QueryResponse> {
    if (query.facet?.length) {
      throw new Error('facets not supported!');
    }
    // get the starred dashboards
    const starsIds = await getBackendSrv().get('api/user/stars');
    if (starsIds?.length) {
      return this.doSearchQuery({
        ...query,
        name: starsIds,
        query: query.query ?? '*',
      });
    }
    // Nothing is starred
    return noDataResponse();
  }

  async tags(query: SearchQuery): Promise<TermCount[]> {
    const qry = query.query ?? '*';
    let uri = `${searchURI}?facet=tags&query=${qry}&limit=1`;
    const resp = await getBackendSrv().get<SearchAPIResponse>(uri);
    return resp.facets?.tags?.terms || [];
  }

  // TODO: Implement this correctly
  getSortOptions(): Promise<SelectableValue[]> {
    const opts: SelectableValue[] = [
      { value: folderViewSort, label: 'Alphabetically (A-Z)' },
      { value: '-name_sort', label: 'Alphabetically (Z-A)' },
    ];

    if (config.licenseInfo.enabledFeatures.analytics) {
      for (const sf of sortFields) {
        opts.push({ value: `-${sf.name}`, label: `${sf.display} (most)` });
        opts.push({ value: `${sf.name}`, label: `${sf.display} (least)` });
      }
    }

    return Promise.resolve(opts);
  }

  async doSearchQuery(query: SearchQuery): Promise<QueryResponse> {
    const uri = await this.newRequest(query);
    const rsp = await this.fetchResponse(uri);

    const first = toDashboardResults(rsp, query.sort ?? '');
    if (first.name === loadingFrameName) {
      return this.fallbackSearcher.search(query);
    }

    const meta = first.meta?.custom || ({} as SearchResultMeta);
    meta.locationInfo = await this.locationInfo;

    // Set the field name to a better display name
    if (meta.sortBy?.length) {
      const field = first.fields.find((f) => f.name === meta.sortBy);
      if (field) {
        const name = getSortFieldDisplayName(field.name);
        meta.sortBy = name;
        field.name = name; // make it look nicer
      }
    }

    let loadMax = 0;
    let pending: Promise<void> | undefined = undefined;
    const getNextPage = async () => {
      while (loadMax > view.dataFrame.length) {
        const offset = view.dataFrame.length;
        if (offset >= meta.count) {
          return;
        }
        const nextPageUrl = `${uri}&offset=${offset}`;
        const resp = await this.fetchResponse(nextPageUrl);
        const frame = toDashboardResults(resp, query.sort ?? '');
        if (!frame) {
          console.log('no results', frame);
          return;
        }
        if (frame.fields.length !== view.dataFrame.fields.length) {
          console.log('invalid shape', frame, view.dataFrame);
          return;
        }

        // Append the raw values to the same array buffer
        const length = frame.length + view.dataFrame.length;
        frame.fields.forEach((f) => {
          const field = view.dataFrame.fields.find((vf) => vf.name === f.name);
          if (field) {
            field.values.push(...f.values);
          }
        });

        view.dataFrame.length = length;

        // Add all the location lookup info
        const submeta = frame.meta?.custom as SearchResultMeta;
        if (submeta?.locationInfo && meta) {
          for (const [key, value] of Object.entries(submeta.locationInfo)) {
            meta.locationInfo[key] = value;
          }
        }
      }
      pending = undefined;
    };

    const view = new DataFrameView<DashboardQueryResult>(first);
    return {
      totalRows: meta.count ?? first.length,
      view,
      loadMoreItems: async (startIndex: number, stopIndex: number): Promise<void> => {
        loadMax = Math.max(loadMax, stopIndex);
        if (!pending) {
          pending = getNextPage();
        }
        return pending;
      },
      isItemLoaded: (index: number): boolean => {
        return index < view.dataFrame.length;
      },
    };
  }

  async fetchResponse(uri: string) {
    const rsp = await getBackendSrv().get<SearchAPIResponse>(uri);
    const isFolderCacheStale = await this.isFolderCacheStale(rsp.hits);
    if (!isFolderCacheStale) {
      return rsp;
    }
    // sync the location info ( folders )
    this.locationInfo = loadLocationInfo();
    // recheck for missing folders
    const hasMissing = await this.isFolderCacheStale(rsp.hits);
    if (!hasMissing) {
      return rsp;
    }

    const locationInfo = await this.locationInfo;
    const hits = rsp.hits.map((hit) => {
      if (hit.folder === undefined) {
        return { ...hit, location: 'general', folder: 'general' };
      }

      // this means user has permission to see this dashboard, but not the folder contents
      if (locationInfo[hit.folder] === undefined) {
        return { ...hit, location: 'sharedwithme', folder: 'sharedwithme' };
      }

      return hit;
    });
    const totalHits = rsp.totalHits - (rsp.hits.length - hits.length);
    return { ...rsp, hits, totalHits };
  }

  async isFolderCacheStale(hits: SearchHit[]): Promise<boolean> {
    const locationInfo = await this.locationInfo;
    return hits.some((hit) => {
      return hit.folder !== undefined && locationInfo[hit.folder] === undefined;
    });
  }

  private async newRequest(query: SearchQuery): Promise<string> {
    query = await replaceCurrentFolderQuery(query);

    let uri = searchURI;
    uri += `?query=${encodeURIComponent(query.query ?? '*')}`;
    uri += `&limit=${query.limit ?? pageSize}`;

    if (!isEmpty(query.location)) {
      uri += `&folder=${query.location}`;
    }

    if (query.kind) {
      // filter resource types
      uri += '&' + query.kind.map((kind) => `type=${kind}`).join('&');
    }

    if (query.tags?.length) {
      uri += '&' + query.tags.map((tag) => `tag=${encodeURIComponent(tag)}`).join('&');
    }

    if (query.sort) {
      const sort = query.sort.replace('_sort', '').replace('name', 'title');
      uri += `&sort=${sort}`;
      const sortField = sort.startsWith('-') ? sort.substring(1) : sort;

      uri += `&field=${sortField}`; // we want to the sort field to be included in the response
    }

    if (query.name?.length) {
      uri += '&' + query.name.map((name) => `name=${encodeURIComponent(name)}`).join('&');
    }

    if (query.uid?.length) {
      // legacy support for filtering by dashboard uid
      uri += '&' + query.uid.map((name) => `name=${encodeURIComponent(name)}`).join('&');
    }
    return uri;
  }

  getFolderViewSort(): string {
    return 'name_sort';
  }
}

const pageSize = 50;

// Enterprise only sort field values for dashboards
const sortFields = [
  { name: 'views_total', display: 'Views total' },
  { name: 'views_last_30_days', display: 'Views 30 days' },
  { name: 'errors_total', display: 'Errors total' },
  { name: 'errors_last_30_days', display: 'Errors 30 days' },
];

function noDataResponse(): QueryResponse | PromiseLike<QueryResponse> {
  return {
    view: new DataFrameView({ length: 0, fields: [] }),
    totalRows: 0,
    loadMoreItems: async (startIndex: number, stopIndex: number): Promise<void> => {
      return;
    },
    isItemLoaded: (index: number): boolean => {
      return true;
    },
  };
}

/** Given the internal field name, this gives a reasonable display name for the table colum header */
function getSortFieldDisplayName(name: string) {
  for (const sf of sortFields) {
    if (sf.name === name) {
      return sf.display;
    }
  }
  return name;
}

export function toDashboardResults(rsp: SearchAPIResponse, sort: string): DataFrame {
  const hits = rsp.hits;
  if (hits.length < 1) {
    return { fields: [], length: 0 };
  }
  const dashboardHits = hits.map((hit) => {
    let location = hit.folder;
    if (hit.resource === 'dashboards' && isEmpty(location)) {
      location = 'general';
    }

    // display null field values as "-"
    const field = Object.fromEntries(
      Object.entries(hit.field ?? {}).map(([key, value]) => [key, value == null ? '-' : value])
    );

    return {
      ...hit,
      uid: hit.name,
      url: toURL(hit.resource, hit.name, hit.title),
      tags: hit.tags || [],
      folder: hit.folder || 'general',
      location,
      name: hit.title, // 🤯 FIXME hit.name is k8s name, eg grafana dashboards UID
      kind: hit.resource.substring(0, hit.resource.length - 1), // dashboard "kind" is not plural
      ...field,
    };
  });
  const frame = toDataFrame(dashboardHits);
  frame.meta = {
    custom: {
      count: rsp.totalHits,
      max_score: 1,
    },
  };
  if (sort && frame.meta.custom) {
    // trim the "-" from sort if it exists
    frame.meta.custom.sortBy = sort.startsWith('-') ? sort.substring(1) : sort;
  }

  for (const field of frame.fields) {
    field.display = getDisplayProcessor({ field, theme: config.theme2 });
  }
  return frame;
}

async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
  const uri = `${searchURI}?type=folders`;
  const rsp = getBackendSrv()
    .get<SearchAPIResponse>(uri)
    .then((rsp) => {
      const locationInfo: Record<string, LocationInfo> = {
        general: {
          kind: 'folder',
          name: 'Dashboards',
          url: '/dashboards',
        }, // share location info with everyone
        sharedwithme: {
          kind: 'sharedwithme',
          name: 'Shared with me',
          url: '',
        },
      };
      for (const hit of rsp.hits) {
        locationInfo[hit.name] = {
          name: hit.title,
          kind: 'folder',
          url: toURL('folders', hit.name, hit.title),
        };
      }
      return locationInfo;
    });
  return rsp;
}

function toURL(resource: string, name: string, title: string): string {
  if (resource === 'folders') {
    return `/dashboards/f/${name}`;
  }
  const slug = kbn.slugifyForUrl(title);
  return `/d/${name}/${slug}`;
}
