import { Logger } from '@nestjs/common';
import { MeiliSearch } from 'meilisearch';
import type { MessageSearchPort, SearchDoc, SearchHit } from './message-search.port';

const INDEX = 'iios_messages';

/** A no-op search port — bound when Meilisearch isn't configured, so search degrades to empty. */
export const noopMessageSearch: MessageSearchPort = {
  ready: () => false,
  index: async () => undefined,
  remove: async () => undefined,
  search: async () => [],
};

/**
 * Meilisearch-backed message search. Configured via MEILI_URL (+ optional MEILI_KEY). The index is
 * created + configured on first use (searchable text/subject; filterable scopeId/threadId/source;
 * sortable at). Filtering by scopeId AND threadId is the permission fence — the caller only ever
 * passes thread ids they belong to.
 */
export class MeiliMessageSearch implements MessageSearchPort {
  private readonly client: MeiliSearch;
  private readonly logger = new Logger(MeiliMessageSearch.name);
  private settingsReady?: Promise<void>;

  constructor(host: string, apiKey?: string) {
    this.client = new MeiliSearch({ host, ...(apiKey ? { apiKey } : {}) });
  }

  ready(): boolean {
    return true;
  }

  /** Idempotently ensure the index + its attribute settings exist (runs once, memoised). */
  private ensure(): Promise<void> {
    if (!this.settingsReady) {
      this.settingsReady = (async () => {
        await this.client.createIndex(INDEX, { primaryKey: 'id' }).catch(() => undefined);
        await this.client.index(INDEX).updateSettings({
          searchableAttributes: ['text', 'subject'],
          filterableAttributes: ['scopeId', 'threadId', 'source'],
          sortableAttributes: ['at'],
        });
      })().catch((err) => {
        this.settingsReady = undefined; // let a later call retry
        throw err;
      });
    }
    return this.settingsReady;
  }

  async index(docs: SearchDoc[]): Promise<void> {
    if (docs.length === 0) return;
    await this.ensure();
    await this.client.index(INDEX).addDocuments(docs, { primaryKey: 'id' });
  }

  async remove(ids: string[]): Promise<void> {
    if (ids.length === 0) return;
    await this.client.index(INDEX).deleteDocuments(ids);
  }

  async search(scopeId: string, threadIds: string[], query: string, limit: number): Promise<SearchHit[]> {
    if (threadIds.length === 0 || !query.trim()) return [];
    await this.ensure();
    const quoted = threadIds.map((t) => JSON.stringify(t)).join(', ');
    const res = await this.client.index(INDEX).search(query, {
      filter: `scopeId = ${JSON.stringify(scopeId)} AND threadId IN [${quoted}]`,
      limit,
      sort: ['at:desc'],
      attributesToHighlight: ['text'],
      highlightPreTag: '<em>',
      highlightPostTag: '</em>',
      attributesToCrop: ['text'],
      cropLength: 30,
    });
    return res.hits.map((h) => {
      const doc = h as unknown as SearchDoc & { _formatted?: { text?: string } };
      return {
        id: doc.id,
        threadId: doc.threadId,
        text: doc.text,
        subject: doc.subject ?? null,
        source: doc.source ?? null,
        at: doc.at,
        snippet: doc._formatted?.text ?? doc.text,
      };
    });
  }
}

/** Build the search port from env: MEILI_URL set → Meilisearch; else the no-op. */
export function messageSearchFromEnv(env: NodeJS.ProcessEnv = process.env): MessageSearchPort {
  const host = env.MEILI_URL?.trim();
  if (!host) {
    new Logger('MessageSearch').log('MEILI_URL unset — message search disabled (no-op)');
    return noopMessageSearch;
  }
  new Logger('MessageSearch').log(`using Meilisearch @ ${host}`);
  return new MeiliMessageSearch(host, env.MEILI_KEY?.trim() || undefined);
}
