/**
 * NCBI PubMed E-utilities adapter.
 *
 * This module is the ONLY place that talks to eutils.ncbi.nlm.nih.gov. Everything
 * else (cache layer, pages, API route) consumes the normalised `Study[]` shape
 * defined in `./types`.
 *
 * Workflow:
 *   1. esearch.fcgi  — given a journal-scoped term, return the newest N PMIDs.
 *   2. efetch.fcgi   — given those PMIDs, return the full MEDLINE XML.
 *   3. parse         — walk the XML and produce `Study` records.
 *
 * NCBI asks that all programmatic callers include `tool=` and `email=`
 * parameters so they can throttle / contact abusers. We comply.
 *
 * All network errors are swallowed at this layer via try/catch and the
 * callers (cache layer, pages) handle an empty array gracefully so the
 * Studies page never hard-fails if PubMed is unreachable.
 */

import { XMLParser } from "fast-xml-parser";

import { buildEsearchTerm } from "./journals";
import type { Study } from "./types";

const EUTILS_BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils";
const POLITE = {
  tool: "orthoglobe",
  email: "contact@orthoglobe.org",
};

/** Maximum number of records we pull per refresh. */
const MAX_RESULTS = 80;

/** Abort long-hanging requests to keep build times sane. */
const FETCH_TIMEOUT_MS = 15_000;

// ---------- low-level HTTP helpers -------------------------------------------------

async function fetchWithTimeout(
  url: string,
  init: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
  const { timeoutMs = FETCH_TIMEOUT_MS, ...rest } = init;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...rest, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

function buildUrl(path: string, params: Record<string, string>): string {
  const qs = new URLSearchParams({ ...POLITE, ...params });
  return `${EUTILS_BASE}/${path}?${qs.toString()}`;
}

// ---------- esearch ---------------------------------------------------------------

type EsearchResponse = {
  esearchresult?: {
    idlist?: string[];
  };
};

async function esearchPmids(term: string, retmax: number): Promise<string[]> {
  const url = buildUrl("esearch.fcgi", {
    db: "pubmed",
    term,
    retmode: "json",
    retmax: String(retmax),
    // PubMed's eutils accepts "pub_date" (not "date") for newest-first ordering —
    // using "date" produces a silent warning and falls back to relevance order,
    // which can miss recently-published PMIDs inside our retmax window.
    sort: "pub_date",
  });
  const res = await fetchWithTimeout(url);
  if (!res.ok) {
    throw new Error(`esearch failed: ${res.status} ${res.statusText}`);
  }
  const data = (await res.json()) as EsearchResponse;
  return data.esearchresult?.idlist ?? [];
}

// ---------- efetch + parse ---------------------------------------------------------

/**
 * fast-xml-parser returns `unknown` shaped objects; we narrow locally with
 * defensive accessors. We intentionally keep the typing loose here because
 * PubMed XML has many optional shapes (single vs array author, missing day,
 * abstract broken into labelled sections, etc.).
 */
type Unknown = Record<string, unknown> | undefined;

function asArray<T>(v: T | T[] | undefined | null): T[] {
  if (v == null) return [];
  return Array.isArray(v) ? v : [v];
}

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

function asString(v: unknown): string {
  if (v == null) return "";
  if (typeof v === "string") return v;
  if (typeof v === "number") return String(v);
  // Mixed content from fast-xml-parser arrives as { "#text": "…", "@_Attr": … }.
  // Crucially, `#text` is parsed to a NUMBER when the node is purely numeric
  // (e.g. <PMID Version="1">41984925</PMID> → { "#text": 41984925 }). We must
  // coerce both string and number `#text` values, otherwise PMID extraction
  // fails and every article is silently dropped.
  const rec = asRecord(v);
  const text = rec?.["#text"];
  if (typeof text === "string") return text;
  if (typeof text === "number") return String(text);
  return "";
}

/** Concatenate mixed-content nodes (string + { '#text': … } fragments). */
function concatText(v: unknown): string {
  if (v == null) return "";
  if (typeof v === "string") return v;
  if (typeof v === "number") return String(v);
  if (Array.isArray(v)) return v.map(concatText).join(" ").trim();
  const rec = asRecord(v);
  if (!rec) return "";
  const text = rec["#text"];
  if (typeof text === "string") return text;
  if (typeof text === "number") return String(text);
  // Labelled abstract sections have `@_Label` + `#text` alongside other children.
  const parts = Object.entries(rec)
    .filter(([k]) => !k.startsWith("@_"))
    .map(([, val]) => concatText(val));
  return parts.join(" ").trim();
}

function pickMonthNumber(month: unknown): string {
  const raw = asString(month).trim();
  if (!raw) return "01";
  // Already numeric?
  if (/^\d{1,2}$/.test(raw)) return raw.padStart(2, "0");
  const table: Record<string, string> = {
    jan: "01",
    feb: "02",
    mar: "03",
    apr: "04",
    may: "05",
    jun: "06",
    jul: "07",
    aug: "08",
    sep: "09",
    oct: "10",
    nov: "11",
    dec: "12",
  };
  return table[raw.slice(0, 3).toLowerCase()] ?? "01";
}

function buildIsoDate(pubDate: Unknown): { iso: string; year: number } {
  const year = Number.parseInt(asString(pubDate?.Year), 10);
  if (!Number.isFinite(year)) {
    // MedlineDate fallback: "2025 Spring" / "2024 Jul-Aug"
    const medline = asString(pubDate?.MedlineDate);
    const m = medline.match(/(\d{4})/);
    if (m) {
      const y = Number.parseInt(m[1]!, 10);
      return { iso: `${y}-01-01`, year: y };
    }
    return { iso: "1970-01-01", year: 1970 };
  }
  const month = pickMonthNumber(pubDate?.Month);
  const day = (() => {
    const raw = asString(pubDate?.Day).trim();
    if (/^\d{1,2}$/.test(raw)) return raw.padStart(2, "0");
    return "01";
  })();
  return { iso: `${year}-${month}-${day}`, year };
}

function extractAuthors(authorList: Unknown): string[] {
  const authors = asArray(authorList?.Author as unknown);
  return authors
    .map((a) => {
      const rec = asRecord(a);
      if (!rec) return "";
      const last = asString(rec.LastName);
      const initials = asString(rec.Initials);
      const collective = asString(rec.CollectiveName);
      if (last) return `${last}${initials ? " " + initials : ""}`.trim();
      return collective.trim();
    })
    .filter(Boolean);
}

function extractDoi(articleIdList: Unknown): string | undefined {
  const ids = asArray(articleIdList?.ArticleId as unknown);
  for (const id of ids) {
    const rec = asRecord(id);
    if (!rec) continue;
    if (asString(rec["@_IdType"]) === "doi") {
      const value = asString(rec["#text"]) || asString(rec);
      if (value) return value;
    }
  }
  return undefined;
}

function parseArticle(pubmedArticle: Unknown): Study | null {
  const medline = asRecord(pubmedArticle?.MedlineCitation);
  const pubmedData = asRecord(pubmedArticle?.PubmedData);
  if (!medline) return null;

  const pmid = asString(medline.PMID);
  if (!pmid) return null;

  const article = asRecord(medline.Article);
  if (!article) return null;

  const rawTitle = concatText(article.ArticleTitle).replace(/\.$/, "").trim();
  if (!rawTitle) return null;

  const journalInfo = asRecord(article.Journal);
  const journalIso =
    asString(journalInfo?.ISOAbbreviation) ||
    asString(journalInfo?.Title) ||
    "";

  const journalIssue = asRecord(journalInfo?.JournalIssue);
  const pubDate = asRecord(journalIssue?.PubDate);
  const { iso, year } = buildIsoDate(pubDate);

  const abstractBlock = asRecord(article.Abstract);
  const abstractText = abstractBlock
    ? concatText(abstractBlock.AbstractText)
    : "";

  const authors = extractAuthors(asRecord(article.AuthorList));
  const doi = extractDoi(asRecord(pubmedData?.ArticleIdList));

  return {
    pmid,
    title: rawTitle,
    authors,
    journal: journalIso,
    pubDate: iso,
    year,
    abstract: abstractText.replace(/\s+/g, " ").trim(),
    doi,
    pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`,
  };
}

async function efetchStudies(pmids: string[]): Promise<Study[]> {
  if (pmids.length === 0) return [];
  const url = buildUrl("efetch.fcgi", {
    db: "pubmed",
    id: pmids.join(","),
    retmode: "xml",
    rettype: "abstract",
  });
  const res = await fetchWithTimeout(url);
  if (!res.ok) {
    throw new Error(`efetch failed: ${res.status} ${res.statusText}`);
  }
  const xml = await res.text();

  const parser = new XMLParser({
    ignoreAttributes: false,
    attributeNamePrefix: "@_",
    textNodeName: "#text",
    trimValues: true,
  });
  const parsed = parser.parse(xml) as Record<string, unknown>;

  const root = asRecord(parsed.PubmedArticleSet);
  const articles = asArray(root?.PubmedArticle as unknown);

  const out: Study[] = [];
  for (const art of articles) {
    const parsedArticle = parseArticle(asRecord(art));
    if (parsedArticle) out.push(parsedArticle);
  }
  return out;
}

// ---------- public entrypoint ------------------------------------------------------

/**
 * Fetch the freshest batch of orthopaedic studies from PubMed.
 *
 * One combined esearch (all allowlisted journals ORed together) gives us the
 * globally newest PMIDs, avoiding N separate per-journal queries. Results are
 * merged and sorted descending by publication date.
 *
 * Returns an empty array on any failure — the UI renders an empty state so
 * the Studies page never blows up if NCBI is unreachable.
 */
export async function fetchStudiesFromPubMed(): Promise<Study[]> {
  try {
    const term = buildEsearchTerm(180);
    const pmids = await esearchPmids(term, MAX_RESULTS);
    if (pmids.length === 0) return [];
    const studies = await efetchStudies(pmids);
    return studies.sort((a, b) => b.pubDate.localeCompare(a.pubDate));
  } catch (err) {
    // Log and degrade gracefully.
    // eslint-disable-next-line no-console
    console.error("[studies] PubMed fetch failed:", err);
    return [];
  }
}
