import type { BibEntry } from './index';
import { getBibField } from './index';

export type ReferenceProviderId =
  | 'local'
  | 'zotero'
  | 'openalex'
  | 'crossref'
  | 'semantic-scholar'
  | 'europe-pmc'
  | 'pubmed'
  | 'arxiv'
  | 'opencitations'
  | 'unpaywall'
  | 'core'
  | 'openaire'
  | 'datacite'
  | 'doaj';

export type ReferenceSort = 'relevance' | 'newest' | 'most-cited';

export type ReferenceSearchFilters = {
  yearFrom?: number;
  yearTo?: number;
  author?: string;
  type?: string;
  openAccessOnly?: boolean;
  doi?: string;
  minCitationCount?: number;
  sort?: ReferenceSort;
};

export type ReferenceRecord = {
  id: string;
  provider: ReferenceProviderId;
  providerId: string;
  title: string;
  authors: readonly string[];
  year?: number;
  venue?: string;
  abstractText?: string;
  doi?: string;
  pmid?: string;
  arxivId?: string;
  url?: string;
  openAccessPdfUrl?: string;
  type?: string;
  citationCount?: number;
  openAccess?: boolean;
  metadataConflicts?: readonly string[];
  sourceProviders?: readonly ReferenceProviderId[];
  relation?: string;
};

export type ReferenceSearchInput = {
  query: string;
  filters?: ReferenceSearchFilters;
  limit?: number;
  cursor?: string;
};

export type ReferenceSearchResult = {
  records: readonly ReferenceRecord[];
  total?: number;
  nextCursor?: string;
};

export type ReferenceCollection = {
  id: string;
  name: string;
  parentId?: string;
};

export interface ReferenceProvider {
  readonly id: ReferenceProviderId;
  readonly label: string;
  readonly requiresConfiguration?: boolean;
  search(input: ReferenceSearchInput, signal?: AbortSignal): Promise<ReferenceSearchResult>;
  lookup?(id: string, signal?: AbortSignal): Promise<ReferenceRecord | undefined>;
  getCollections?(signal?: AbortSignal): Promise<readonly ReferenceCollection[]>;
}

export type ReferenceFetch = (
  input: string | URL,
  init?: RequestInit,
) => Promise<Response>;

function asRecord(value: unknown): Record<string, unknown> | undefined {
  return typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined;
}

function asArray(value: unknown): unknown[] {
  return Array.isArray(value) ? value : [];
}

function asString(value: unknown): string | undefined {
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}

function asNumber(value: unknown): number | undefined {
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}

function plainText(value: unknown): string | undefined {
  const text = asString(value);
  if (!text) return undefined;
  const normalized = text
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;/gi, ' ')
    .replace(/&amp;/gi, '&')
    .replace(/&lt;/gi, '<')
    .replace(/&gt;/gi, '>')
    .replace(/&quot;/gi, '"')
    .replace(/&#39;/gi, "'")
    .replace(/\s+/g, ' ')
    .trim();
  return normalized || undefined;
}

function openAlexAbstract(value: unknown): string | undefined {
  const index = asRecord(value);
  if (!index) return undefined;
  const words: Array<{ token: string; position: number }> = [];
  for (const [token, positions] of Object.entries(index)) {
    for (const position of asArray(positions)) {
      if (typeof position === 'number' && Number.isFinite(position)) words.push({ token, position });
    }
  }
  words.sort((a, b) => a.position - b.position);
  return words.length ? words.map((item) => item.token).join(' ') : undefined;
}

function stripDoi(value: unknown): string | undefined {
  const text = asString(value);
  if (!text) return undefined;
  return text
    .replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '')
    .replace(/^doi:\s*/i, '')
    .trim()
    .toLowerCase() || undefined;
}

function normalizeArxiv(value: unknown): string | undefined {
  const text = asString(value);
  if (!text) return undefined;
  return text.replace(/^arxiv:\s*/i, '').trim() || undefined;
}

async function readJson(response: Response): Promise<unknown> {
  if (!response.ok) {
    const body = await response.text().catch(() => '');
    throw new Error(`Reference provider request failed (${response.status})${body ? `: ${body.slice(0, 180)}` : ''}`);
  }
  return response.json() as Promise<unknown>;
}

function boundedLimit(input: number | undefined): number {
  return Math.max(1, Math.min(50, Math.trunc(input ?? 25)));
}

function yearFromDateParts(value: unknown): number | undefined {
  const parts = asRecord(value)?.['date-parts'];
  const first = asArray(parts)[0];
  const year = Array.isArray(first) ? first[0] : undefined;
  return typeof year === 'number' && Number.isFinite(year) ? year : undefined;
}

function firstString(value: unknown): string | undefined {
  return asArray(value).map(asString).find(Boolean);
}

function compact<T>(values: readonly (T | undefined | null | false)[]): T[] {
  return values.filter(Boolean) as T[];
}

function applyCitationCountFilter(
  records: ReferenceRecord[],
  filters: ReferenceSearchFilters | undefined,
): ReferenceRecord[] {
  const minimum = filters?.minCitationCount;
  if (minimum === undefined || !Number.isFinite(minimum) || minimum <= 0) return records;
  return records.filter((record) => (record.citationCount ?? 0) >= minimum);
}

export function createLocalBibliographyReferenceProvider<T extends BibEntry>(entries: readonly T[]): ReferenceProvider {
  return {
    id: 'local',
    label: 'Local bibliography',
    async search(input) {
      const needle = input.query.trim().toLowerCase();
      const records = entries.map((entry): ReferenceRecord => ({
        id: `local:${entry.key}`,
        provider: 'local',
        providerId: entry.key,
        title: getBibField(entry, 'title')?.value.trim() || entry.key,
        authors: (getBibField(entry, 'author')?.value ?? '').split(/\s+and\s+/i).map((value) => value.trim()).filter(Boolean),
        year: Number(getBibField(entry, 'year')?.value) || undefined,
        venue: getBibField(entry, 'journal')?.value ?? getBibField(entry, 'booktitle')?.value,
        abstractText: getBibField(entry, 'abstract')?.value,
        doi: stripDoi(getBibField(entry, 'doi')?.value),
        arxivId: bibArxiv(entry),
        type: entry.type,
        url: getBibField(entry, 'url')?.value,
      })).filter((record) => {
        if (!needle) return true;
        return [record.providerId, record.title, ...record.authors, record.venue, record.doi, record.arxivId].filter(Boolean).join('\n').toLowerCase().includes(needle);
      });
      return { records: records.slice(0, boundedLimit(input.limit)), total: records.length };
    },
  };
}

export function createOpenAlexReferenceProvider(options: {
  fetch: ReferenceFetch;
  apiKey?: string;
}): ReferenceProvider {
  return {
    id: 'openalex',
    label: 'OpenAlex',
    async search(input, signal) {
      const params = new URLSearchParams();
      const query = input.query.trim();
      if (query) params.set('search', query);
      params.set('per_page', String(boundedLimit(input.limit)));
      if (input.cursor) params.set('cursor', input.cursor);
      else params.set('page', '1');
      if (options.apiKey?.trim()) params.set('api_key', options.apiKey.trim());

      const filters: string[] = [];
      const f = input.filters;
      if (f?.yearFrom) filters.push(`from_publication_date:${f.yearFrom}-01-01`);
      if (f?.yearTo) filters.push(`to_publication_date:${f.yearTo}-12-31`);
      if (f?.openAccessOnly) filters.push('is_oa:true');
      if (f?.doi) filters.push(`doi:${stripDoi(f.doi) ?? f.doi}`);
      if (f?.type) filters.push(`type:${f.type}`);
      if (f?.author) filters.push(`raw_author_name.search:${f.author.replace(/,/g, ' ')}`);
      if (filters.length) params.set('filter', filters.join(','));
      if (f?.sort === 'newest') params.set('sort', 'publication_date:desc');
      if (f?.sort === 'most-cited') params.set('sort', 'cited_by_count:desc');

      const payload = asRecord(await readJson(await options.fetch(
        `https://api.openalex.org/works?${params.toString()}`,
        { signal },
      )));
      const records = asArray(payload?.results).map((value): ReferenceRecord | undefined => {
        const work = asRecord(value);
        const title = asString(work?.display_name) ?? asString(work?.title);
        const providerId = asString(work?.id);
        if (!work || !title || !providerId) return undefined;
        const authors = asArray(work.authorships)
          .map((authorship) => asString(asRecord(asRecord(authorship)?.author)?.display_name))
          .filter((author): author is string => Boolean(author));
        const primaryLocation = asRecord(work.primary_location);
        const source = asRecord(primaryLocation?.source);
        const oa = asRecord(work.open_access);
        return {
          id: `openalex:${providerId}`,
          provider: 'openalex',
          providerId,
          title,
          authors,
          year: asNumber(work.publication_year),
          venue: asString(source?.display_name),
          abstractText: openAlexAbstract(work.abstract_inverted_index),
          doi: stripDoi(work.doi),
          type: asString(work.type),
          citationCount: asNumber(work.cited_by_count),
          openAccess: typeof oa?.is_oa === 'boolean' ? oa.is_oa : undefined,
          url: safeExternalUrl(stripDoi(work.doi) ? `https://doi.org/${stripDoi(work.doi)}` : providerId),
        };
      }).filter((record): record is ReferenceRecord => Boolean(record));
      const filtered = applyCitationCountFilter(records, f);
      const meta = asRecord(payload?.meta);
      return {
        records: filtered,
        total: asNumber(meta?.count),
        nextCursor: asString(meta?.next_cursor),
      };
    },
  };
}

export function createCrossrefReferenceProvider(options: {
  fetch: ReferenceFetch;
  mailto?: string;
}): ReferenceProvider {
  return {
    id: 'crossref',
    label: 'Crossref',
    async search(input, signal) {
      const params = new URLSearchParams();
      const query = input.query.trim();
      if (query) params.set('query.bibliographic', query);
      params.set('rows', String(boundedLimit(input.limit)));
      if (options.mailto?.trim()) params.set('mailto', options.mailto.trim());
      const f = input.filters;
      const filters: string[] = [];
      if (f?.yearFrom) filters.push(`from-pub-date:${f.yearFrom}-01-01`);
      if (f?.yearTo) filters.push(`until-pub-date:${f.yearTo}-12-31`);
      if (filters.length) params.set('filter', filters.join(','));
      if (f?.author) params.set('query.author', f.author);
      if (f?.sort === 'newest') {
        params.set('sort', 'published');
        params.set('order', 'desc');
      } else if (f?.sort === 'most-cited') {
        params.set('sort', 'is-referenced-by-count');
        params.set('order', 'desc');
      }

      const payload = asRecord(await readJson(await options.fetch(
        `https://api.crossref.org/works?${params.toString()}`,
        { signal },
      )));
      const message = asRecord(payload?.message);
      let records = asArray(message?.items).map((value): ReferenceRecord | undefined => {
        const work = asRecord(value);
        const title = firstString(work?.title);
        const doi = stripDoi(work?.DOI);
        if (!work || !title) return undefined;
        const authors = asArray(work.author).map((authorValue) => {
          const author = asRecord(authorValue);
          const given = asString(author?.given);
          const family = asString(author?.family);
          return [given, family].filter(Boolean).join(' ').trim() || undefined;
        }).filter((author): author is string => Boolean(author));
        const url = safeExternalUrl(asString(work.URL) ?? (doi ? `https://doi.org/${doi}` : undefined));
        const providerId = doi ?? url ?? `${title}:${yearFromDateParts(work.published) ?? ''}`;
        const license = asArray(work.license);
        const clearlyOpenAccess = license.some((item) => {
          const url = asString(asRecord(item)?.URL) ?? asString(asRecord(item)?.url);
          return Boolean(url && /creativecommons\.org|creativecommons\.org\/publicdomain/i.test(url));
        });
        return {
          id: `crossref:${providerId}`,
          provider: 'crossref',
          providerId,
          title,
          authors,
          year: yearFromDateParts(work.published) ?? yearFromDateParts(work.issued),
          venue: firstString(work['container-title']),
          abstractText: plainText(work.abstract),
          doi,
          type: asString(work.type),
          citationCount: asNumber(work['is-referenced-by-count']),
          openAccess: clearlyOpenAccess ? true : undefined,
          url,
        };
      }).filter((record): record is ReferenceRecord => Boolean(record));
      if (f?.doi) records = records.filter((record) => record.doi === stripDoi(f.doi));
      if (f?.type) { const type = f.type.toLowerCase(); records = records.filter((record) => record.type?.toLowerCase() === type); }
      if (f?.openAccessOnly) records = records.filter((record) => record.openAccess);
      records = applyCitationCountFilter(records, f);
      return { records, total: asNumber(message?.['total-results']) };
    },
  };
}

export function createSemanticScholarReferenceProvider(options: {
  fetch: ReferenceFetch;
  apiKey?: string;
}): ReferenceProvider {
  return {
    id: 'semantic-scholar',
    label: 'Semantic Scholar',
    async search(input, signal) {
      const query = input.query.trim();
      if (!query) return { records: [] };
      const params = new URLSearchParams({
        query,
        limit: String(boundedLimit(input.limit)),
        fields: 'paperId,title,abstract,authors,year,venue,externalIds,citationCount,openAccessPdf,url,publicationTypes',
      });
      const f = input.filters;
      if (f?.yearFrom || f?.yearTo) params.set('year', `${f.yearFrom ?? ''}-${f.yearTo ?? ''}`);
      const headers = new Headers();
      if (options.apiKey?.trim()) headers.set('x-api-key', options.apiKey.trim());
      const payload = asRecord(await readJson(await options.fetch(
        `https://api.semanticscholar.org/graph/v1/paper/search?${params.toString()}`,
        { signal, headers },
      )));
      let records = asArray(payload?.data).map((value): ReferenceRecord | undefined => {
        const paper = asRecord(value);
        const providerId = asString(paper?.paperId);
        const title = asString(paper?.title);
        if (!paper || !providerId || !title) return undefined;
        const externalIds = asRecord(paper.externalIds);
        const openAccessPdf = asRecord(paper.openAccessPdf);
        return {
          id: `semantic-scholar:${providerId}`,
          provider: 'semantic-scholar',
          providerId,
          title,
          authors: asArray(paper.authors).map((author) => asString(asRecord(author)?.name)).filter((name): name is string => Boolean(name)),
          year: asNumber(paper.year),
          venue: asString(paper.venue),
          abstractText: plainText(paper.abstract),
          doi: stripDoi(externalIds?.DOI),
          arxivId: normalizeArxiv(externalIds?.ArXiv),
          type: asString(asArray(paper.publicationTypes)[0]),
          citationCount: asNumber(paper.citationCount),
          openAccess: Boolean(asString(openAccessPdf?.url)),
          url: safeExternalUrl(asString(paper.url) ?? asString(openAccessPdf?.url)),
        };
      }).filter((record): record is ReferenceRecord => Boolean(record));
      if (f?.author) records = records.filter((record) => record.authors.some((author) => author.toLowerCase().includes(f.author!.toLowerCase())));
      if (f?.doi) records = records.filter((record) => record.doi === stripDoi(f.doi));
      if (f?.openAccessOnly) records = records.filter((record) => record.openAccess);
      if (f?.type) records = records.filter((record) => record.type?.toLowerCase() === f.type!.toLowerCase());
      if (f?.sort === 'newest') records.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
      if (f?.sort === 'most-cited') records.sort((a, b) => (b.citationCount ?? 0) - (a.citationCount ?? 0));
      records = applyCitationCountFilter(records, f);
      return { records, total: asNumber(payload?.total) };
    },
  };
}

export function createZoteroReferenceProvider(options: {
  fetch: ReferenceFetch;
  libraryType?: 'users' | 'groups';
  libraryId: string;
  apiKey?: string;
  collectionKey?: string;
}): ReferenceProvider {
  const libraryType = options.libraryType ?? 'users';
  const prefix = `${libraryType}/${encodeURIComponent(options.libraryId.trim())}`;
  const request = async (path: string, params: URLSearchParams, signal?: AbortSignal) => {
    const headers = new Headers({ 'Zotero-API-Version': '3' });
    if (options.apiKey?.trim()) headers.set('Zotero-API-Key', options.apiKey.trim());
    return readJson(await options.fetch(`https://api.zotero.org/${prefix}/${path}?${params.toString()}`, { signal, headers }));
  };
  return {
    id: 'zotero',
    label: 'Zotero',
    requiresConfiguration: true,
    async search(input, signal) {
      if (!options.libraryId.trim()) throw new Error('Enter a Zotero user or group library ID.');
      const params = new URLSearchParams({
        format: 'json',
        include: 'data',
        limit: String(boundedLimit(input.limit)),
        itemType: '-attachment',
        sort: input.filters?.sort === 'newest' ? 'date' : 'dateModified',
        direction: 'desc',
      });
      if (input.query.trim()) {
        params.set('q', input.query.trim());
        params.set('qmode', 'everything');
      }
      if (input.cursor) params.set('start', input.cursor);
      const path = options.collectionKey?.trim()
        ? `collections/${encodeURIComponent(options.collectionKey.trim())}/items/top`
        : 'items/top';
      const payload = await request(path, params, signal);
      let records = asArray(payload).map((value): ReferenceRecord | undefined => {
        const item = asRecord(value);
        const data = asRecord(item?.data) ?? item;
        const providerId = asString(item?.key) ?? asString(data?.key);
        const title = asString(data?.title);
        if (!providerId || !title) return undefined;
        const creators = asArray(data?.creators).map((value) => {
          const creator = asRecord(value);
          const name = asString(creator?.name);
          if (name) return name;
          return compact([asString(creator?.firstName), asString(creator?.lastName)]).join(' ') || undefined;
        }).filter((author): author is string => Boolean(author));
        const date = asString(data?.date);
        const yearMatch = date?.match(/\b(18|19|20|21)\d{2}\b/);
        const extra = asString(data?.extra) ?? '';
        const arxiv = extra.match(/arXiv:\s*([^\s]+)/i)?.[1];
        const doi = stripDoi(data?.DOI);
        return {
          id: `zotero:${providerId}`,
          provider: 'zotero',
          providerId,
          title,
          authors: creators,
          year: yearMatch ? Number(yearMatch[0]) : undefined,
          venue: asString(data?.publicationTitle) ?? asString(data?.conferenceName) ?? asString(data?.publisher),
          abstractText: plainText(data?.abstractNote),
          doi,
          arxivId: normalizeArxiv(arxiv),
          type: asString(data?.itemType),
          url: safeExternalUrl(asString(data?.url) ?? (doi ? `https://doi.org/${doi}` : undefined)),
        };
      }).filter((record): record is ReferenceRecord => Boolean(record));
      const f = input.filters;
      if (f?.yearFrom) records = records.filter((record) => (record.year ?? 0) >= f.yearFrom!);
      if (f?.yearTo) records = records.filter((record) => (record.year ?? Number.MAX_SAFE_INTEGER) <= f.yearTo!);
      if (f?.author) records = records.filter((record) => record.authors.some((author) => author.toLowerCase().includes(f.author!.toLowerCase())));
      if (f?.doi) records = records.filter((record) => record.doi === stripDoi(f.doi));
      if (f?.type) records = records.filter((record) => record.type?.toLowerCase() === f.type!.toLowerCase());
      records = applyCitationCountFilter(records, f);
      return { records, nextCursor: String(Number(input.cursor ?? 0) + records.length) };
    },
    async getCollections(signal) {
      if (!options.libraryId.trim()) return [];
      const payload = await request('collections', new URLSearchParams({ format: 'json', limit: '100' }), signal);
      return asArray(payload).map((value): ReferenceCollection | undefined => {
        const collection = asRecord(value);
        const data = asRecord(collection?.data) ?? collection;
        const id = asString(collection?.key) ?? asString(data?.key);
        const name = asString(data?.name);
        if (!id || !name) return undefined;
        return { id, name, parentId: asString(data?.parentCollection) };
      }).filter((collection): collection is ReferenceCollection => Boolean(collection));
    },
  };
}

function normalizedText(value: string | undefined): string {
  return (value ?? '')
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[^a-z0-9]+/g, ' ')
    .trim();
}

function firstAuthorFamily(authors: readonly string[]): string {
  const first = (authors[0] ?? '').replace(/[{}]/g, '').trim();
  if (!first) return '';
  if (first.includes(',')) return first.split(',')[0]?.trim() ?? '';
  const parts = first.split(/\s+/);
  return parts.at(-1) ?? '';
}

function bibDoi(entry: BibEntry): string | undefined {
  return stripDoi(getBibField(entry, 'doi')?.value);
}

function bibArxiv(entry: BibEntry): string | undefined {
  const archivePrefix = getBibField(entry, 'archiveprefix')?.value.toLowerCase();
  const eprint = normalizeArxiv(getBibField(entry, 'eprint')?.value);
  if (archivePrefix === 'arxiv' && eprint) return eprint.toLowerCase();
  return normalizeArxiv(getBibField(entry, 'arxiv')?.value)?.toLowerCase();
}

export type ReferenceMatch = {
  entry: BibEntry;
  confidence: 'doi' | 'stable-id' | 'title-author-year';
};

export function matchReferenceToBibliography<T extends BibEntry>(
  reference: ReferenceRecord,
  entries: readonly T[],
): (ReferenceMatch & { entry: T }) | undefined {
  const doi = stripDoi(reference.doi);
  if (doi) {
    const entry = entries.find((candidate) => bibDoi(candidate) === doi);
    if (entry) return { entry, confidence: 'doi' };
  }
  const arxiv = normalizeArxiv(reference.arxivId)?.toLowerCase();
  if (arxiv) {
    const entry = entries.find((candidate) => bibArxiv(candidate) === arxiv);
    if (entry) return { entry, confidence: 'stable-id' };
  }
  const title = normalizedText(reference.title);
  const author = normalizedText(firstAuthorFamily(reference.authors));
  if (!title || !reference.year || !author) return undefined;
  const entry = entries.find((candidate) => {
    const candidateTitle = normalizedText(getBibField(candidate, 'title')?.value);
    const candidateYear = Number(getBibField(candidate, 'year')?.value);
    const candidateAuthor = normalizedText(firstAuthorFamily([getBibField(candidate, 'author')?.value ?? '']));
    return candidateTitle === title && candidateYear === reference.year && candidateAuthor.includes(author);
  });
  return entry ? { entry, confidence: 'title-author-year' } : undefined;
}

export function dedupeReferenceRecords(records: readonly ReferenceRecord[]): ReferenceRecord[] {
  const output: ReferenceRecord[] = [];
  for (const record of records) {
    const index = output.findIndex((candidate) => {
      if (record.doi && candidate.doi && stripDoi(record.doi) === stripDoi(candidate.doi)) return true;
      if (record.arxivId && candidate.arxivId && normalizeArxiv(record.arxivId)?.toLowerCase() === normalizeArxiv(candidate.arxivId)?.toLowerCase()) return true;
      return Boolean(record.year && candidate.year && record.year === candidate.year &&
        normalizedText(record.title) === normalizedText(candidate.title) &&
        normalizedText(firstAuthorFamily(record.authors)) === normalizedText(firstAuthorFamily(candidate.authors)));
    });
    if (index < 0) {
      output.push({ ...record, sourceProviders: record.sourceProviders ?? [record.provider] });
      continue;
    }
    const existing = output[index]!;
    const conflicts = new Set(existing.metadataConflicts ?? []);
    if (normalizedText(existing.title) && normalizedText(record.title) && normalizedText(existing.title) !== normalizedText(record.title)) conflicts.add('title');
    if (existing.year && record.year && existing.year !== record.year) conflicts.add('year');
    if (firstAuthorFamily(existing.authors) && firstAuthorFamily(record.authors) && normalizedText(firstAuthorFamily(existing.authors)) !== normalizedText(firstAuthorFamily(record.authors))) conflicts.add('author');
    if (existing.venue && record.venue && normalizedText(existing.venue) !== normalizedText(record.venue)) conflicts.add('venue');
    output[index] = {
      ...existing,
      authors: existing.authors.length >= record.authors.length ? existing.authors : record.authors,
      year: existing.year ?? record.year,
      venue: existing.venue ?? record.venue,
      doi: existing.doi ?? record.doi,
      arxivId: existing.arxivId ?? record.arxivId,
      url: existing.url ?? record.url,
      type: existing.type ?? record.type,
      citationCount: Math.max(existing.citationCount ?? 0, record.citationCount ?? 0) || undefined,
      openAccess: existing.openAccess ?? record.openAccess,
      metadataConflicts: conflicts.size ? [...conflicts] : undefined,
      sourceProviders: [...new Set([...(existing.sourceProviders ?? [existing.provider]), ...(record.sourceProviders ?? [record.provider])])],
    };
  }
  return output;
}

function compactSingleLine(value: string): string {
  let compacted = '';
  for (const character of value) {
    const codePoint = character.codePointAt(0) ?? 0;
    compacted += codePoint <= 0x1f || codePoint === 0x7f ? ' ' : character;
  }
  return compacted.replace(/\s{2,}/g, ' ').trim();
}

/**
 * External reference metadata is plain text, not trusted LaTeX. Escape TeX
 * metacharacters before inserting it into a .bib file so a provider record
 * cannot terminate a field or inject commands into the bibliography source.
 */
function bibTextValue(value: string): string {
  return compactSingleLine(value)
    .replace(/\\/g, '\\textbackslash{}')
    .replace(/([{}%#&_$])/g, '\\$1')
    .replace(/\^/g, '\\^{}')
    .replace(/~/g, '\\~{}');
}

function safeExternalUrl(value: string | undefined): string | undefined {
  const text = value ? compactSingleLine(value) : '';
  if (!text || /[{}\\]/.test(text)) return undefined;
  try {
    const url = new URL(text);
    return url.protocol === 'https:' || url.protocol === 'http:' ? text : undefined;
  } catch {
    return undefined;
  }
}

function safeBibKey(key: string): string {
  const trimmed = key.trim();
  if (!/^[A-Za-z0-9][A-Za-z0-9:._+\/-]*$/.test(trimmed)) {
    throw new Error('Invalid bibliography key.');
  }
  return trimmed;
}

function bibType(reference: ReferenceRecord): string {
  const type = reference.type?.toLowerCase() ?? '';
  if (type.includes('book')) return 'book';
  if (type.includes('conference') || type.includes('proceedings')) return 'inproceedings';
  if (type.includes('thesis')) return 'phdthesis';
  if (type.includes('report')) return 'techreport';
  return 'article';
}

export function suggestCitationKey(reference: ReferenceRecord, existingKeys: ReadonlySet<string>): string {
  const family = firstAuthorFamily(reference.authors).replace(/[^A-Za-z0-9]/g, '').toLowerCase() || 'ref';
  const year = reference.year ? String(reference.year) : 'nd';
  const stop = new Set(['a', 'an', 'the', 'of', 'for', 'and', 'in', 'on', 'to', 'with']);
  const word = normalizedText(reference.title).split(' ').find((part) => part.length > 2 && !stop.has(part)) ?? 'work';
  const base = `${family}${year}${word}`.replace(/[^a-z0-9:_-]/g, '') || `ref${year}`;
  if (!existingKeys.has(base)) return base;
  for (let suffix = 97; suffix <= 122; suffix += 1) {
    const key = `${base}${String.fromCharCode(suffix)}`;
    if (!existingKeys.has(key)) return key;
  }
  let counter = 2;
  while (existingKeys.has(`${base}${counter}`)) counter += 1;
  return `${base}${counter}`;
}

export function createBibTeXEntry(reference: ReferenceRecord, key: string): string {
  const entryType = bibType(reference);
  const safeKey = safeBibKey(key);
  const venueField = entryType === 'inproceedings' ? 'booktitle' : entryType === 'book' ? 'publisher' : 'journal';
  const doi = stripDoi(reference.doi);
  const arxivId = normalizeArxiv(reference.arxivId);
  const url = safeExternalUrl(reference.url);
  const fields = compact([
    reference.title ? ['title', bibTextValue(reference.title)] as const : undefined,
    reference.authors.length ? ['author', reference.authors.map(bibTextValue).join(' and ')] as const : undefined,
    reference.year ? ['year', String(reference.year)] as const : undefined,
    reference.venue ? [venueField, bibTextValue(reference.venue)] as const : undefined,
    doi ? ['doi', bibTextValue(doi)] as const : undefined,
    arxivId ? ['eprint', bibTextValue(arxivId)] as const : undefined,
    arxivId ? ['archiveprefix', 'arXiv'] as const : undefined,
    url ? ['url', url] as const : undefined,
  ]);
  return `@${entryType}{${safeKey},\n${fields.map(([name, value]) => `  ${name} = {${value}}`).join(',\n')}\n}\n`;
}
