/*!
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {
  Injectable,
  InjectionToken,
  Provider,
  inject,
  linkedSignal,
  resource,
  signal,
} from '@angular/core';
import {ENVIRONMENT} from '../providers/index';
import type {Environment, SearchResult, SearchResultItem, SnippetResult} from '../interfaces/index';
import {
  LiteClient,
  liteClient as algoliasearch,
  SearchResponses,
  SearchResult as AlgoliaSearchResult,
} from 'algoliasearch/lite';

export const SEARCH_DELAY = 200;
// Maximum number of facet values to return for each facet during a regular search.
export const MAX_VALUE_PER_FACET = 5;

export const ALGOLIA_CLIENT: InjectionToken<LiteClient> = new InjectionToken<LiteClient>(
  'Search service',
);

export const provideAlgoliaSearchClient = (config: Environment): Provider => {
  return {
    provide: ALGOLIA_CLIENT,
    useFactory: () => algoliasearch(config.algolia.appId, config.algolia.apiKey),
  };
};

@Injectable({
  providedIn: 'root',
})
export class Search {
  readonly searchQuery = signal('');

  private readonly config = inject(ENVIRONMENT);
  private readonly client = inject(ALGOLIA_CLIENT);

  readonly resultsResource = resource({
    params: () => this.searchQuery() || undefined, // coerces empty string to undefined
    loader: async ({params: query, abortSignal}) => {
      // Until we have a better alternative we debounce by awaiting for a short delay.
      await wait(SEARCH_DELAY, abortSignal);
      return this.searchWithQuery(query);
    },
  });

  readonly searchResults = linkedSignal<SearchResultItem[] | undefined, SearchResultItem[]>({
    source: this.resultsResource.value,
    computation: (next, prev) => (!next && this.searchQuery() ? prev?.value : next) ?? [],
  });

  private getUniqueSearchResultItems(items: SearchResult[]): SearchResult[] {
    const uniqueUrls = new Set<string>();

    return items.filter((item) => {
      if (item.type === 'content' && !item._snippetResult.content) {
        return false;
      }
      // Ensure that this result actually matched on the type.
      // If not, this is going to be a duplicate. There should be another result in
      // the list that already matched on its type.
      // A lvl2 match will also return all its lvl3 results as well, even if those
      // values don't also match the query.
      if (
        item.type.indexOf('lvl') === 0 &&
        item._snippetResult.hierarchy?.[item.type as 'lvl1']?.matchLevel === 'none'
      ) {
        return false;
      }

      if (item['url'] && typeof item['url'] === 'string' && !uniqueUrls.has(item['url'])) {
        uniqueUrls.add(item['url']);
        return true;
      }
      return false;
    });
  }

  private parseResult(response: SearchResponses<unknown>): SearchResultItem[] | undefined {
    if (!response) {
      return;
    }

    const result: AlgoliaSearchResult = response.results[0];
    if (!result || !('hits' in result)) {
      return;
    }

    const items = result.hits as unknown as SearchResult[];

    return this.getUniqueSearchResultItems(items).map((hitItem: SearchResult): SearchResultItem => {
      const content = hitItem._snippetResult.content;
      const hierarchy = hitItem._snippetResult.hierarchy;
      const category = hitItem.hierarchy?.lvl0 ?? null;

      const lvl1Value = hierarchy?.lvl1?.value || hitItem.hierarchy?.lvl1;

      const sublabelSnippet = this.getBestSnippetForMatch(hitItem);

      // If no lvl1, promote sublabel to label to avoid empty titles
      const label = lvl1Value || sublabelSnippet || '';

      const hasSubLabel = lvl1Value && (hierarchy?.lvl2 || hierarchy?.lvl3 || hierarchy?.lvl4);

      return {
        id: hitItem.objectID,
        type: hitItem.hierarchy.lvl0 === 'Tutorials' ? 'code' : 'doc',
        url: hitItem.url,

        labelHtml: this.parseLabelToHtml(label),
        subLabelHtml: this.parseLabelToHtml(hasSubLabel ? sublabelSnippet : null),
        contentHtml: content ? this.parseLabelToHtml(content.value) : null,
        package: category === 'Reference' ? extractPackageNameFromUrl(hitItem.url) : null,

        category: hitItem.hierarchy?.lvl0 ?? null,
      };
    });
  }

  private getBestSnippetForMatch(result: SearchResult): string {
    const hierarchy = result._snippetResult.hierarchy;
    if (hierarchy === undefined) {
      return '';
    }

    // return the most specific subheader match
    if (matched(hierarchy.lvl4)) {
      return hierarchy.lvl4!.value;
    }
    if (matched(hierarchy.lvl3)) {
      return hierarchy.lvl3!.value;
    }
    if (matched(hierarchy.lvl2)) {
      return hierarchy.lvl2!.value;
    }
    // if no subheader matched the query, fall back to just returning the most specific one
    return hierarchy.lvl3?.value ?? hierarchy.lvl2?.value ?? '';
  }

  /**
   * Returns an HTML string with marked text for the matches
   */
  private parseLabelToHtml(label: string | null): string | null {
    if (label === null) {
      return null;
    }

    const parts: Array<{highlight: boolean; text: string}> = [];
    while (label.indexOf('<ɵ>') !== -1) {
      const beforeMatch = label.substring(0, label.indexOf('<ɵ>'));
      const match = label.substring(label.indexOf('<ɵ>') + 3, label.indexOf('</ɵ>'));
      parts.push({highlight: false, text: beforeMatch});
      parts.push({highlight: true, text: match});
      label = label.substring(label.indexOf('</ɵ>') + 4);
    }
    parts.push({highlight: false, text: label});

    return parts
      .map((part) => {
        return part.highlight ? `<mark>${part.text}</mark>` : `<span>${part.text}</span>`;
      })
      .join('');
  }

  public searchWithQuery(query: string): Promise<SearchResultItem[] | undefined> {
    return this.client
      .search([
        {
          indexName: this.config.algolia.indexName,
          params: {
            query: query,
            maxValuesPerFacet: MAX_VALUE_PER_FACET,
            attributesToRetrieve: [
              'hierarchy.lvl0',
              'hierarchy.lvl1',
              'hierarchy.lvl2',
              'hierarchy.lvl3',
              'hierarchy.lvl4',
              'hierarchy.lvl5',
              'hierarchy.lvl6',
              'content',
              'type',
              'url',
            ],
            hitsPerPage: 20,
            snippetEllipsisText: '…',
            highlightPreTag: '<ɵ>',
            highlightPostTag: '</ɵ>',
            attributesToHighlight: [],
            attributesToSnippet: [
              'hierarchy.lvl1:10',
              'hierarchy.lvl2:10',
              'hierarchy.lvl3:10',
              'hierarchy.lvl4:10',
              'hierarchy.lvl5:10',
              'hierarchy.lvl6:10',
              'content:10',
            ],
          },
          type: 'default',
        },
      ])
      .then((response: SearchResponses<unknown>) => {
        return this.parseResult(response);
      });
  }
}

function matched(snippet: SnippetResult | undefined): boolean {
  return snippet?.matchLevel !== undefined && snippet.matchLevel !== 'none';
}

/**
 * Temporary helper to implement the debounce functionality on the search resource
 */
function wait(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise<void>((resolve, reject) => {
    let timeout: ReturnType<typeof setTimeout> | undefined;

    const onAbort = () => {
      clearTimeout(timeout);
      reject(new Error('Operation aborted'));
    };

    timeout = setTimeout(() => {
      signal.removeEventListener('abort', onAbort);
      resolve();
    }, ms);

    signal.addEventListener('abort', onAbort, {once: true});
  });
}

function extractPackageNameFromUrl(url: string): string | null {
  const extractedSegment = url.match(/\/api\/(.*)\/.*#?/);
  if (extractedSegment == null) {
    return null;
  }
  return `<code>@angular/${extractedSegment[1]}</code>`;
}
