/**
 * @beignet/core/search
 *
 * Provider-neutral search primitives for Beignet applications.
 */

import {
  createProvider,
  createProviderInstrumentation,
} from "../providers/index.js";

/**
 * Primitive values accepted in indexed documents and provider-neutral filters.
 */
export type SearchPrimitive = null | boolean | number | string;

/**
 * JSON-like values accepted in indexed documents.
 */
export type SearchValue =
  | SearchPrimitive
  | readonly SearchValue[]
  | { readonly [key: string]: SearchValue };

/**
 * Minimal document shape accepted by search indexes.
 */
export type SearchDocumentBase = {
  id: string;
};

/**
 * General search document shape.
 */
export type SearchDocument = SearchDocumentBase & {
  readonly [field: string]: SearchValue | undefined;
};

type SearchField<TDocument extends SearchDocumentBase> = Extract<
  keyof TDocument,
  string
>;

/**
 * Provider-neutral index definition.
 */
export type SearchIndexDef<
  TDocument extends SearchDocumentBase = SearchDocument,
> = {
  kind: "search-index";
  name: string;
  primaryKey: SearchField<TDocument>;
  searchableAttributes?: readonly SearchField<TDocument>[];
  filterableAttributes?: readonly SearchField<TDocument>[];
  sortableAttributes?: readonly SearchField<TDocument>[];
  displayedAttributes?: readonly SearchField<TDocument>[];
  metadata?: Record<string, unknown>;
};

/**
 * Options accepted when defining a search index.
 */
export type DefineSearchIndexOptions<TDocument extends SearchDocumentBase> =
  Omit<SearchIndexDef<TDocument>, "kind" | "name" | "primaryKey"> & {
    primaryKey?: SearchField<TDocument>;
  };

/**
 * Provider-neutral filter values.
 */
export type SearchFilterValue =
  | SearchPrimitive
  | readonly Exclude<SearchPrimitive, null>[];

/**
 * Provider-neutral exact-match filters.
 */
export type SearchFilters = Record<string, SearchFilterValue>;

/**
 * Provider-neutral sort expression. Use `field:asc` or `field:desc`.
 */
export type SearchSort = `${string}:asc` | `${string}:desc` | (string & {});

/**
 * Query accepted by search providers.
 */
export type SearchQuery = {
  query?: string;
  filters?: SearchFilters;
  sort?: readonly SearchSort[];
  facets?: readonly string[];
  limit?: number;
  offset?: number;
};

/**
 * Page metadata returned from search providers.
 */
export type SearchResultPage = {
  kind: "offset";
  limit: number;
  offset: number;
  total?: number;
  hasMore: boolean;
};

/**
 * Search result payload.
 */
export type SearchResults<TDocument extends SearchDocumentBase> = {
  hits: TDocument[];
  query: string;
  page: SearchResultPage;
  processingTimeMs?: number;
  facets?: Record<string, Record<string, number>>;
};

/**
 * Result returned after indexing documents.
 */
export type SearchIndexResult = {
  indexed: number;
  taskId?: string | number;
};

/**
 * Result returned after deleting indexed documents.
 */
export type SearchDeleteResult = {
  deleted: number;
  taskId?: string | number;
};

/**
 * App-facing search port.
 */
export type SearchPort = {
  configureIndex<TDocument extends SearchDocumentBase>(
    index: SearchIndexDef<TDocument>,
  ): Promise<void>;
  indexDocuments<TDocument extends SearchDocumentBase>(
    index: SearchIndexDef<TDocument>,
    documents: TDocument | readonly TDocument[],
  ): Promise<SearchIndexResult>;
  deleteDocuments<TDocument extends SearchDocumentBase>(
    index: SearchIndexDef<TDocument>,
    ids: string | readonly string[],
  ): Promise<SearchDeleteResult>;
  clearIndex<TDocument extends SearchDocumentBase>(
    index: SearchIndexDef<TDocument>,
  ): Promise<SearchDeleteResult>;
  search<TDocument extends SearchDocumentBase>(
    index: SearchIndexDef<TDocument>,
    query: SearchQuery,
  ): Promise<SearchResults<TDocument>>;
};

/**
 * Captured memory index state exposed for tests.
 */
export type MemorySearchIndexState<
  TDocument extends SearchDocumentBase = SearchDocument,
> = {
  definition?: SearchIndexDef<TDocument>;
  documents: Map<string, TDocument>;
};

/**
 * In-memory search port exposed for assertions in tests.
 */
export type MemorySearchPort = SearchPort & {
  indexes: Map<string, MemorySearchIndexState>;
  reset(index?: SearchIndexDef): void;
};

/**
 * Options for the in-memory search adapter.
 */
export type CreateMemorySearchOptions = {
  now?: () => Date;
};

/**
 * Options for the in-memory search provider.
 */
export type MemorySearchProviderOptions = CreateMemorySearchOptions & {
  name?: string;
};

/**
 * Ports contributed by the memory search provider.
 */
export interface MemorySearchProviderPorts {
  search: SearchPort;
}

/**
 * Error thrown when search inputs are invalid.
 */
export class SearchOptionsError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "SearchOptionsError";
  }
}

/**
 * Define a typed search index.
 */
export function defineSearchIndex<
  TDocument extends SearchDocumentBase = SearchDocument,
>(
  name: string,
  options: DefineSearchIndexOptions<TDocument> = {},
): SearchIndexDef<TDocument> {
  if (!name) throw new SearchOptionsError("Search index name is required.");

  return {
    kind: "search-index",
    name,
    primaryKey: options.primaryKey ?? ("id" as SearchField<TDocument>),
    searchableAttributes: options.searchableAttributes,
    filterableAttributes: options.filterableAttributes,
    sortableAttributes: options.sortableAttributes,
    displayedAttributes: options.displayedAttributes,
    metadata: options.metadata,
  };
}

/**
 * Index documents through any `SearchPort`.
 */
export function indexSearchDocuments<TDocument extends SearchDocumentBase>(
  search: SearchPort,
  index: SearchIndexDef<TDocument>,
  documents: TDocument | readonly TDocument[],
): Promise<SearchIndexResult> {
  return search.indexDocuments(index, documents);
}

/**
 * Query documents through any `SearchPort`.
 */
export function searchDocuments<TDocument extends SearchDocumentBase>(
  search: SearchPort,
  index: SearchIndexDef<TDocument>,
  query: SearchQuery,
): Promise<SearchResults<TDocument>> {
  return search.search(index, query);
}

/**
 * Create an in-memory search port for tests and single-process development.
 */
export function createMemorySearch(
  _options: CreateMemorySearchOptions = {},
): MemorySearchPort {
  const indexes = new Map<string, MemorySearchIndexState>();

  const port: MemorySearchPort = {
    indexes,
    async configureIndex(index) {
      validateIndex(index);
      stateFor(indexes, index).definition = index;
    },
    async indexDocuments(index, documents) {
      validateIndex(index);
      const state = stateFor(indexes, index);
      state.definition = state.definition ?? index;
      const list = Array.isArray(documents) ? documents : [documents];

      for (const document of list) {
        validateDocument(index, document);
        state.documents.set(
          String(document[index.primaryKey]),
          clone(document),
        );
      }

      return { indexed: list.length };
    },
    async deleteDocuments(index, ids) {
      validateIndex(index);
      const state = stateFor(indexes, index);
      const list = Array.isArray(ids) ? ids : [ids];
      let deleted = 0;

      for (const id of list) {
        if (state.documents.delete(id)) deleted++;
      }

      return { deleted };
    },
    async clearIndex(index) {
      validateIndex(index);
      const state = stateFor(indexes, index);
      const deleted = state.documents.size;
      state.documents.clear();
      return { deleted };
    },
    async search(index, query) {
      validateIndex(index);
      validateQuery(query);
      const state = stateFor(indexes, index);
      const effectiveIndex = state.definition ?? index;
      const queryText = query.query?.trim() ?? "";
      const limit = query.limit ?? 20;
      const offset = query.offset ?? 0;
      const filtered = [...state.documents.values()]
        .filter((document) => matchesFilters(document, query.filters))
        .filter((document) =>
          matchesQuery(document, effectiveIndex, queryText),
        );
      const sorted = sortDocuments(filtered, query.sort);
      const hits = sorted.slice(offset, offset + limit).map(clone);

      return {
        hits,
        query: queryText,
        page: {
          kind: "offset",
          limit,
          offset,
          total: filtered.length,
          hasMore: offset + hits.length < filtered.length,
        },
        facets: buildFacets(sorted, query.facets),
      };
    },
    reset(index) {
      if (index) {
        indexes.delete(index.name);
        return;
      }

      indexes.clear();
    },
  };

  return port;
}

/**
 * Create a provider that contributes an in-memory search port.
 */
export function createMemorySearchProvider(
  options: MemorySearchProviderOptions = {},
) {
  const { name = "memory-search", ...searchOptions } = options;

  return createProvider({
    name,
    metadata: {
      ports: ["search"],
      watchers: ["search"],
    },
    setup({ ports }) {
      const instrumentation = createProviderInstrumentation(ports, {
        providerName: name,
        watcher: "search",
      });

      return {
        ports: {
          search: instrumentSearch(
            createMemorySearch(searchOptions),
            instrumentation,
          ),
        } satisfies MemorySearchProviderPorts,
      };
    },
  });
}

function instrumentSearch(
  search: SearchPort,
  instrumentation: ReturnType<typeof createProviderInstrumentation>,
): SearchPort {
  return {
    async configureIndex(index) {
      const startedAt = Date.now();
      await search.configureIndex(index);
      instrumentation.custom({
        name: "search.configureIndex",
        label: "Search index configured",
        summary: index.name,
        details: { index: index.name, durationMs: Date.now() - startedAt },
      });
    },
    async indexDocuments(index, documents) {
      const startedAt = Date.now();
      const result = await search.indexDocuments(index, documents);
      instrumentation.custom({
        name: "search.indexDocuments",
        label: "Search documents indexed",
        summary: `${index.name}: ${result.indexed}`,
        details: {
          index: index.name,
          indexed: result.indexed,
          durationMs: Date.now() - startedAt,
        },
      });
      return result;
    },
    async deleteDocuments(index, ids) {
      const startedAt = Date.now();
      const result = await search.deleteDocuments(index, ids);
      instrumentation.custom({
        name: "search.deleteDocuments",
        label: "Search documents deleted",
        summary: `${index.name}: ${result.deleted}`,
        details: {
          index: index.name,
          deleted: result.deleted,
          durationMs: Date.now() - startedAt,
        },
      });
      return result;
    },
    async clearIndex(index) {
      const startedAt = Date.now();
      const result = await search.clearIndex(index);
      instrumentation.custom({
        name: "search.clearIndex",
        label: "Search index cleared",
        summary: `${index.name}: ${result.deleted}`,
        details: {
          index: index.name,
          deleted: result.deleted,
          durationMs: Date.now() - startedAt,
        },
      });
      return result;
    },
    async search(index, query) {
      const startedAt = Date.now();
      const result = await search.search(index, query);
      instrumentation.custom({
        name: "search.query",
        label: "Search query",
        summary: `${index.name}: ${result.query}`,
        details: {
          index: index.name,
          query: result.query,
          hits: result.hits.length,
          total: result.page.total,
          durationMs: Date.now() - startedAt,
        },
      });
      return result;
    },
  };
}

function stateFor<TDocument extends SearchDocumentBase>(
  indexes: Map<string, MemorySearchIndexState>,
  index: SearchIndexDef<TDocument>,
): MemorySearchIndexState<TDocument> {
  let state = indexes.get(index.name);
  if (!state) {
    state = { documents: new Map() };
    indexes.set(index.name, state);
  }
  return state as unknown as MemorySearchIndexState<TDocument>;
}

function validateIndex<TDocument extends SearchDocumentBase>(
  index: SearchIndexDef<TDocument>,
) {
  if (!index.name)
    throw new SearchOptionsError("Search index name is required.");
  if (!index.primaryKey) {
    throw new SearchOptionsError("Search index primaryKey is required.");
  }
}

function validateDocument<TDocument extends SearchDocumentBase>(
  index: SearchIndexDef<TDocument>,
  document: TDocument,
) {
  const id = document[index.primaryKey];
  if (typeof id !== "string" || id.length === 0) {
    throw new SearchOptionsError(
      `Search document for index "${index.name}" must include a non-empty "${index.primaryKey}" string.`,
    );
  }
}

function validateQuery(query: SearchQuery) {
  if (
    query.limit !== undefined &&
    (!Number.isFinite(query.limit) ||
      !Number.isInteger(query.limit) ||
      query.limit < 1)
  ) {
    throw new SearchOptionsError(
      "Search query limit must be a positive integer.",
    );
  }
  if (
    query.offset !== undefined &&
    (!Number.isFinite(query.offset) ||
      !Number.isInteger(query.offset) ||
      query.offset < 0)
  ) {
    throw new SearchOptionsError(
      "Search query offset must be a zero or greater integer.",
    );
  }
}

function matchesFilters(
  document: SearchDocumentBase,
  filters: SearchFilters | undefined,
): boolean {
  if (!filters) return true;

  return Object.entries(filters).every(([field, expected]) => {
    const actual = (document as Record<string, unknown>)[field];
    return matchesFilterValue(actual, expected);
  });
}

function matchesFilterValue(
  actual: unknown,
  expected: SearchFilterValue,
): boolean {
  if (Array.isArray(expected)) {
    if (Array.isArray(actual)) {
      return actual.some((entry) =>
        expected.some((expectedEntry) => expectedEntry === entry),
      );
    }

    return expected.some((entry) => entry === actual);
  }

  if (Array.isArray(actual)) {
    return actual.some((entry) => entry === expected);
  }

  return actual === expected;
}

function matchesQuery<TDocument extends SearchDocumentBase>(
  document: TDocument,
  index: SearchIndexDef<TDocument>,
  query: string,
): boolean {
  if (!query) return true;

  const needle = query.toLowerCase();
  const fields =
    index.searchableAttributes ??
    (Object.keys(document) as SearchField<TDocument>[]);

  return fields.some((field) =>
    searchableText((document as Record<string, unknown>)[field])
      .toLowerCase()
      .includes(needle),
  );
}

function sortDocuments<TDocument extends SearchDocumentBase>(
  documents: TDocument[],
  sort: readonly SearchSort[] | undefined,
): TDocument[] {
  if (!sort || sort.length === 0) return documents;

  return [...documents].sort((left, right) => {
    for (const expression of sort) {
      const [field, direction = "asc"] = expression.split(":");
      const result = compareValues(
        (left as Record<string, unknown>)[field],
        (right as Record<string, unknown>)[field],
      );
      if (result !== 0) {
        return direction === "desc" ? -result : result;
      }
    }

    return 0;
  });
}

function compareValues(left: unknown, right: unknown): number {
  if (left === right) return 0;
  if (left === undefined || left === null) return 1;
  if (right === undefined || right === null) return -1;
  if (typeof left === "number" && typeof right === "number") {
    return left - right;
  }

  return String(left).localeCompare(String(right));
}

function buildFacets<TDocument extends SearchDocumentBase>(
  documents: readonly TDocument[],
  facets: readonly string[] | undefined,
): Record<string, Record<string, number>> | undefined {
  if (!facets || facets.length === 0) return undefined;

  const result: Record<string, Record<string, number>> = {};
  for (const facet of facets) {
    const counts: Record<string, number> = {};
    for (const document of documents) {
      const value = (document as Record<string, unknown>)[facet];
      const values = Array.isArray(value) ? value : [value];
      for (const entry of values) {
        if (entry === undefined || entry === null) continue;
        const key = String(entry);
        counts[key] = (counts[key] ?? 0) + 1;
      }
    }
    result[facet] = counts;
  }

  return result;
}

function searchableText(value: unknown): string {
  if (value === undefined || value === null) return "";
  if (Array.isArray(value)) return value.map(searchableText).join(" ");
  if (typeof value === "object") {
    return Object.values(value).map(searchableText).join(" ");
  }

  return String(value);
}

function clone<T>(value: T): T {
  return JSON.parse(JSON.stringify(value)) as T;
}
