import type { BibEntry } from './index';
import type { ReferenceRecord, ReferenceSearchFilters } from './reference-provider';

export type ReferenceResearchIntent =
  | 'supporting'
  | 'contradicting'
  | 'seminal'
  | 'recent'
  | 'original-source'
  | 'related';

export type ReferenceResearchSuggestion = {
  record: ReferenceRecord;
  score: number;
  reasons: readonly string[];
  /**
   * Metadata/abstract retrieval can identify candidates, not prove that a
   * paper supports or contradicts a manuscript claim. Full-text verification
   * remains a researcher action (and later Paper Memory can provide it).
   */
  requiresFullTextVerification: boolean;
};

export type LiteratureShortlistState = 'candidate' | 'included' | 'excluded';

export type LiteratureShortlistItem = {
  id: string;
  reference: ReferenceRecord;
  state: LiteratureShortlistState;
  notes?: string;
  tags?: readonly string[];
  reason?: string;
  addedAt: number;
  updatedAt: number;
};

export type ProbableDoiMatch = {
  entryKey: string;
  doi: string;
  record: ReferenceRecord;
  confidence: number;
  reasons: readonly string[];
};

const STOP_WORDS = new Set([
  'a', 'an', 'and', 'are', 'as', 'at', 'be', 'been', 'by', 'for', 'from',
  'has', 'have', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'our', 'that',
  'the', 'their', 'this', 'to', 'using', 'was', 'we', 'were', 'with',
  // Generic academic prose is weak evidence of topical relevance. Keeping
  // these out prevents long unrelated abstracts/issues from matching a claim
  // merely because they contain words such as “study”, “results” or “data”.
  'approach', 'based', 'better', 'data', 'different', 'given', 'known', 'method',
  'paper', 'proposed', 'research', 'result', 'results', 'set', 'study', 'task',
  'terms', 'value', 'values', 'work',
]);


function bibFieldValue(entry: BibEntry, name: string): string | undefined {
  return entry.fields.find((field) => field.name.toLowerCase() === name.toLowerCase())?.value;
}

const CONTRADICTION_CUES = [
  'contrary', 'contradict', 'challenge', 'fails to', 'failed to', 'no evidence',
  'not significant', 'does not', 'do not', 'did not', 'however', 'inconsistent',
  'question', 'refute', 'disagree', 'limitation',
];

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

function tokens(value: string | undefined): Set<string> {
  const result = new Set<string>();
  for (const token of normalizedText(value).split(' ')) {
    if (token.length < 2 || STOP_WORDS.has(token)) continue;
    result.add(token);
  }
  return result;
}

function overlapScore(left: Set<string>, right: Set<string>): number {
  if (!left.size || !right.size) return 0;
  let matches = 0;
  for (const token of left) if (right.has(token)) matches += 1;
  return matches / Math.max(1, Math.min(left.size, right.size));
}

function overlapCount(left: Set<string>, right: Set<string>): number {
  let matches = 0;
  for (const token of left) if (right.has(token)) matches += 1;
  return matches;
}

function recordPassesTopicalGate(queryTokens: Set<string>, record: ReferenceRecord): boolean {
  if (!queryTokens.size) return false;
  // Provider bugs occasionally surface an entire issue/index page as one
  // record. Do not let a metadata dump compete with actual papers.
  if (record.title.trim().length > 600 || (record.abstractText?.length ?? 0) > 20_000) return false;
  const titleTokens = tokens(record.title);
  const abstractTokens = tokens(record.abstractText);
  const titleMatches = overlapCount(queryTokens, titleTokens);
  const abstractMatches = overlapCount(queryTokens, abstractTokens);
  const distinctMatches = overlapCount(queryTokens, new Set([...titleTokens, ...abstractTokens]));
  const minimumMatches = queryTokens.size <= 3 ? 1 : 2;
  if (distinctMatches < minimumMatches) return false;
  const titleOverlap = overlapScore(queryTokens, titleTokens);
  const abstractOverlap = overlapScore(queryTokens, abstractTokens);
  return titleMatches >= minimumMatches
    || titleOverlap >= 0.16
    || (abstractMatches >= minimumMatches && abstractOverlap >= 0.08);
}

const QUERY_STOP_WORDS = new Set([
  ...STOP_WORDS,
  'also', 'automatically', 'case', 'does', 'even', 'fixed', 'large', 'number',
  'objects', 'performs', 'procedure', 'recursive', 'require', 'requires', 'special',
  'starts', 'stopping', 'until',
]);

/** Build a compact provider query from a manuscript claim without mutating the
 * displayed claim. LaTeX citation keys/commands and generic academic filler are
 * removed so provider relevance is driven by technical terms and names. */
export function buildReferenceResearchQuery(claim: string, maxTerms = 18): string {
  const cleaned = claim
    .replace(/\\(?:cite|citep|citet|autocite|parencite|textcite|ref|eqref|pageref|autoref|cref|Cref|label)\s*(?:\[[^\]]*\]\s*)?\{[^}]*\}/gu, ' ')
    .replace(/\\[A-Za-z@]+\*?(?:\[[^\]]*\])?/gu, ' ')
    .replace(/[{}$]/gu, '')
    .replace(/\\(?:textit|textbf|emph)\b/gu, ' ');
  const rawTerms = cleaned.match(/[\p{L}][\p{L}\p{N}'’_-]{1,}/gu) ?? [];
  const terms: string[] = [];
  const seen = new Set<string>();
  for (const raw of rawTerms) {
    const normalized = normalizedText(raw).replace(/\s+/gu, '');
    if (normalized.length < 2 || QUERY_STOP_WORDS.has(normalized) || seen.has(normalized)) continue;
    seen.add(normalized);
    terms.push(raw.replace(/[_]/gu, '-'));
    if (terms.length >= Math.max(4, Math.min(30, Math.trunc(maxTerms)))) break;
  }
  return terms.length >= 2 ? terms.join(' ') : claim.trim();
}

function normalizedCitationScore(count: number | undefined): number {
  if (!count || count <= 0) return 0;
  return Math.min(1, Math.log10(count + 1) / 4);
}

function recencyScore(year: number | undefined, currentYear: number): number {
  if (!year) return 0;
  const age = Math.max(0, currentYear - year);
  return Math.max(0, 1 - age / 15);
}

function historicalScore(year: number | undefined, currentYear: number): number {
  if (!year) return 0;
  const age = Math.max(0, currentYear - year);
  return Math.min(1, age / 25);
}

function contradictionCueScore(record: ReferenceRecord): number {
  const haystack = normalizedText(`${record.title}\n${record.abstractText ?? ''}`);
  let score = 0;
  for (const cue of CONTRADICTION_CUES) {
    if (haystack.includes(normalizedText(cue))) score += 1;
  }
  return Math.min(1, score / 3);
}

/**
 * Rank provider-backed candidates without pretending metadata proves the
 * scientific relationship. Supporting/contradicting results are explicitly
 * candidates until the paper itself is inspected.
 */
export function rankReferenceResearchCandidates(
  records: readonly ReferenceRecord[],
  query: string,
  intent: ReferenceResearchIntent,
  currentYear = new Date().getFullYear(),
): ReferenceResearchSuggestion[] {
  const queryTokens = tokens(query);
  return records
    .filter((record) => recordPassesTopicalGate(queryTokens, record))
    .map((record): ReferenceResearchSuggestion => {
      const titleOverlap = overlapScore(queryTokens, tokens(record.title));
      const abstractOverlap = overlapScore(queryTokens, tokens(record.abstractText));
      const lexical = Math.max(titleOverlap, abstractOverlap * 0.9);
      const citations = normalizedCitationScore(record.citationCount);
      const recent = recencyScore(record.year, currentYear);
      const historical = historicalScore(record.year, currentYear);
      const contradiction = contradictionCueScore(record);

      let score = lexical;
      const reasons: string[] = [];
      if (lexical >= 0.35) reasons.push('strong title/abstract term overlap');
      else if (lexical > 0) reasons.push('partial title/abstract term overlap');
      if (record.citationCount !== undefined) reasons.push(`${record.citationCount} provider-reported citations`);
      if (record.year) reasons.push(`published ${record.year}`);

      switch (intent) {
        case 'supporting':
          score = lexical * 0.78 + citations * 0.14 + recent * 0.08;
          reasons.push('candidate supporting source — verify the paper text');
          break;
        case 'contradicting':
          score = lexical * 0.58 + contradiction * 0.27 + citations * 0.1 + recent * 0.05;
          if (contradiction > 0) reasons.push('abstract/title contains challenge or negation cues');
          reasons.push('candidate contradicting source — verify the paper text');
          break;
        case 'seminal':
          score = lexical * 0.35 + citations * 0.45 + historical * 0.2;
          reasons.push('ranked toward influential earlier work');
          break;
        case 'recent':
          score = lexical * 0.55 + recent * 0.4 + citations * 0.05;
          reasons.push('ranked toward recent relevant work');
          break;
        case 'original-source':
          score = lexical * 0.5 + historical * 0.3 + citations * 0.2;
          reasons.push('candidate earlier/original source — verify priority from the paper and citation graph');
          break;
        case 'related':
          score = lexical * 0.8 + citations * 0.12 + recent * 0.08;
          reasons.push('ranked by topical overlap');
          break;
      }
      return {
        record,
        score: Math.max(0, Math.min(1, score)),
        reasons,
        requiresFullTextVerification: intent === 'supporting' || intent === 'contradicting' || intent === 'original-source',
      };
    })
    .filter((item) => item.score > 0)
    .sort((a, b) => b.score - a.score || (b.record.citationCount ?? 0) - (a.record.citationCount ?? 0));
}

export function researchFiltersForIntent(
  filters: ReferenceSearchFilters,
  intent: ReferenceResearchIntent,
): ReferenceSearchFilters {
  if (intent === 'recent') return { ...filters, sort: 'newest' };
  if (intent === 'seminal') return { ...filters, sort: 'most-cited' };
  return { ...filters, sort: filters.sort ?? 'relevance' };
}

function firstAuthorFamily(value: string | undefined): string {
  const author = (value ?? '').split(/\s+and\s+/i)[0]?.replace(/[{}]/g, '').trim() ?? '';
  if (!author) return '';
  if (author.includes(',')) return normalizedText(author.split(',')[0]);
  return normalizedText(author.split(/\s+/).at(-1));
}

function recordFirstAuthorFamily(record: ReferenceRecord): string {
  const author = record.authors[0]?.replace(/[{}]/g, '').trim() ?? '';
  if (!author) return '';
  if (author.includes(',')) return normalizedText(author.split(',')[0]);
  return normalizedText(author.split(/\s+/).at(-1));
}

function titleSimilarity(left: string, right: string): number {
  const a = tokens(left);
  const b = tokens(right);
  if (!a.size || !b.size) return 0;
  let intersection = 0;
  for (const token of a) if (b.has(token)) intersection += 1;
  const union = new Set([...a, ...b]).size;
  return union ? intersection / union : 0;
}

/**
 * Only returns a DOI candidate when title plus available author/year metadata
 * agree strongly. This is a suggestion for researcher review, never an
 * automatic bibliography mutation.
 */
export function findProbableMissingDoi(
  entry: BibEntry,
  candidates: readonly ReferenceRecord[],
): ProbableDoiMatch | undefined {
  if (bibFieldValue(entry, 'doi')?.trim()) return undefined;
  const title = bibFieldValue(entry, 'title')?.trim();
  if (!title) return undefined;
  const entryYear = Number(bibFieldValue(entry, 'year')) || undefined;
  const entryAuthor = firstAuthorFamily(bibFieldValue(entry, 'author'));

  const ranked = candidates
    .filter((record): record is ReferenceRecord & { doi: string } => Boolean(record.doi))
    .map((record) => {
      const titleScore = titleSimilarity(title, record.title);
      const recordAuthor = recordFirstAuthorFamily(record);
      const authorMatches = !entryAuthor || !recordAuthor || entryAuthor === recordAuthor;
      const yearMatches = !entryYear || !record.year || entryYear === record.year;
      const confidence = titleScore * 0.78 + (authorMatches ? 0.12 : 0) + (yearMatches ? 0.1 : 0);
      return { record, titleScore, authorMatches, yearMatches, confidence };
    })
    .filter((item) => item.titleScore >= 0.82 && item.authorMatches && item.yearMatches)
    .sort((a, b) => b.confidence - a.confidence);

  if (!ranked.length || ranked[0]!.confidence < 0.86) return undefined;
  const best = ranked[0]!;
  const second = ranked[1];
  if (second && best.confidence - second.confidence < 0.03 && best.record.doi !== second.record.doi) return undefined;
  return {
    entryKey: entry.key,
    doi: best.record.doi,
    record: best.record,
    confidence: best.confidence,
    reasons: [
      'high normalized-title agreement',
      ...(entryAuthor ? ['first-author agreement'] : []),
      ...(entryYear ? ['publication-year agreement'] : []),
    ],
  };
}

export function createShortlistItem(
  reference: ReferenceRecord,
  now = Date.now(),
): LiteratureShortlistItem {
  return {
    id: reference.id,
    reference,
    state: 'candidate',
    addedAt: now,
    updatedAt: now,
  };
}

export function migrateShortlistRecords(
  value: unknown,
  now = Date.now(),
): LiteratureShortlistItem[] {
  if (!Array.isArray(value)) return [];
  const items: LiteratureShortlistItem[] = [];
  for (const candidate of value) {
    if (!candidate || typeof candidate !== 'object') continue;
    const object = candidate as Record<string, unknown>;
    if (object.reference && typeof object.reference === 'object') {
      const reference = object.reference as ReferenceRecord;
      if (!reference.id || !reference.title) continue;
      const state = object.state === 'included' || object.state === 'excluded' ? object.state : 'candidate';
      items.push({
        id: typeof object.id === 'string' ? object.id : reference.id,
        reference,
        state,
        ...(typeof object.notes === 'string' && object.notes.trim() ? { notes: object.notes } : {}),
        ...(Array.isArray(object.tags) ? { tags: object.tags.filter((tag): tag is string => typeof tag === 'string' && Boolean(tag.trim())) } : {}),
        ...(typeof object.reason === 'string' && object.reason.trim() ? { reason: object.reason } : {}),
        addedAt: typeof object.addedAt === 'number' ? object.addedAt : now,
        updatedAt: typeof object.updatedAt === 'number' ? object.updatedAt : now,
      });
      continue;
    }
    const reference = object as unknown as ReferenceRecord;
    if (typeof reference.id === 'string' && typeof reference.title === 'string') {
      items.push(createShortlistItem(reference, now));
    }
  }
  const unique = new Map(items.map((item) => [item.reference.id, item]));
  return [...unique.values()];
}
