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

import type { RawItem } from "./types";

/**
 * Minimal RSS 2.0 / Atom feed adapter for the news aggregator.
 *
 * We use `fast-xml-parser` (already a project dep) with a conservative
 * configuration — we want arrays to always be arrays so we don't have to
 * re-check `Array.isArray` for every `channel/item` lookup, and we keep
 * text content under a single predictable key.
 *
 * Design constraints:
 *   - Must not throw on malformed or empty feeds; returns `[]` instead.
 *   - Must handle BOTH RSS 2.0 (`rss/channel/item`) and Atom (`feed/entry`).
 *   - Must strip HTML from descriptions so list pages render plaintext.
 *   - Must not import anything server-only so it can be unit tested.
 */

const ARRAY_PATHS = new Set([
  "rss.channel.item",
  "feed.entry",
  "rss.channel.item.category",
  "feed.entry.category",
  "feed.entry.link",
  "rss.channel.item.enclosure",
  "rss.channel.item.media:content",
  "rss.channel.item.media:thumbnail",
  "feed.entry.media:content",
  "feed.entry.media:thumbnail",
]);

const parser = new XMLParser({
  ignoreAttributes: false,
  attributeNamePrefix: "@_",
  textNodeName: "#text",
  // `jPath: true` asks fast-xml-parser to pass the current path as a
  // plain string to every callback, instead of the richer `MatcherView`
  // object. We only need string comparisons so this keeps typing simple.
  jPath: true,
  // Force these paths to always be arrays, avoiding single-item
  // "object vs array" branches in the normalisation logic below.
  isArray: (_name, jpath) => {
    // With jPath: true the second arg is typed as `string | MatcherView`;
    // narrow to string defensively in case the declared union lies.
    const path = typeof jpath === "string" ? jpath : jpath.toString();
    return ARRAY_PATHS.has(path);
  },
});

/**
 * Resolve any of the many shapes RSS/Atom feeds use for textual fields
 * down to a single string. fast-xml-parser may give us:
 *   - a raw string ("Some title")
 *   - an object with a `#text` key (when attributes were present)
 *   - an object with a nested CDATA-ish structure
 * We also accept numbers because some feeds emit bare publication dates.
 */
function readText(value: unknown): string {
  if (value == null) return "";
  if (typeof value === "string") return value;
  if (typeof value === "number" || typeof value === "boolean") {
    return String(value);
  }
  if (typeof value === "object") {
    const obj = value as Record<string, unknown>;
    if (typeof obj["#text"] === "string") return obj["#text"] as string;
    if (typeof obj["#text"] === "number") return String(obj["#text"]);
  }
  return "";
}

/**
 * Extract the outbound article URL from an Atom `<link>` entry. Atom
 * feeds often expose multiple <link rel="..."> entries (self, alternate,
 * enclosure); we prefer the alternate or the first one with an href.
 */
function readAtomLink(link: unknown): string {
  if (!link) return "";
  const list = Array.isArray(link) ? link : [link];
  // Prefer rel="alternate" or missing rel (which defaults to alternate).
  for (const item of list) {
    if (typeof item === "string") return item;
    if (item && typeof item === "object") {
      const obj = item as Record<string, unknown>;
      const rel = typeof obj["@_rel"] === "string" ? obj["@_rel"] : "";
      const href = typeof obj["@_href"] === "string" ? obj["@_href"] : "";
      if (href && (rel === "" || rel === "alternate")) return href;
    }
  }
  // Fallback: first href we see at all.
  for (const item of list) {
    if (item && typeof item === "object") {
      const href = (item as Record<string, unknown>)["@_href"];
      if (typeof href === "string") return href;
    }
  }
  return "";
}

/**
 * Strip HTML tags, collapse whitespace, decode a handful of common
 * numeric and named entities, and cap length. We deliberately avoid a
 * full HTML sanitiser here — excerpts are already rendered as text, so
 * "remove anything that looks like a tag" is sufficient and far cheaper.
 */
export function stripHtml(input: string, maxLength = 220): string {
  if (!input) return "";
  const withoutTags = input
    .replace(/<script[\s\S]*?<\/script>/gi, "")
    .replace(/<style[\s\S]*?<\/style>/gi, "")
    .replace(/<[^>]+>/g, " ");
  const decoded = withoutTags
    .replace(/&nbsp;/g, " ")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&#8217;/g, "\u2019")
    .replace(/&#8220;/g, "\u201C")
    .replace(/&#8221;/g, "\u201D")
    .replace(/&hellip;/g, "\u2026");
  const collapsed = decoded.replace(/\s+/g, " ").trim();
  if (collapsed.length <= maxLength) return collapsed;
  return `${collapsed.slice(0, maxLength - 1).trimEnd()}\u2026`;
}

/**
 * Walk a feed-item record and try to find the first usable image URL.
 *
 * We check, in order:
 *   1. RSS 2.0 `<enclosure type="image/..." url="...">`
 *   2. Yahoo Media RSS `<media:content url="..." medium="image">` (and
 *      the unqualified case where `medium` is missing but type starts with "image/")
 *   3. Yahoo Media RSS `<media:thumbnail url="...">`
 *   4. Inline `<img src="...">` in content:encoded / description / summary / content
 *
 * The shape fast-xml-parser returns for each of these is a bit different
 * (array vs object vs nested attrs), so we normalise defensively. If
 * nothing matches, returns `undefined` so the UI can fall through to a
 * gradient placeholder.
 */
function extractImageUrl(item: Record<string, unknown>): string | undefined {
  // 1) RSS enclosure
  const enclosure = item.enclosure;
  const enclosures = Array.isArray(enclosure)
    ? enclosure
    : enclosure
      ? [enclosure]
      : [];
  for (const enc of enclosures) {
    if (enc && typeof enc === "object") {
      const obj = enc as Record<string, unknown>;
      const type = typeof obj["@_type"] === "string" ? (obj["@_type"] as string) : "";
      const url = typeof obj["@_url"] === "string" ? (obj["@_url"] as string) : "";
      if (url && (!type || type.toLowerCase().startsWith("image/"))) {
        return url;
      }
    }
  }

  // 2) media:content
  const mediaContent = item["media:content"];
  const mcList = Array.isArray(mediaContent)
    ? mediaContent
    : mediaContent
      ? [mediaContent]
      : [];
  for (const mc of mcList) {
    if (mc && typeof mc === "object") {
      const obj = mc as Record<string, unknown>;
      const url = typeof obj["@_url"] === "string" ? (obj["@_url"] as string) : "";
      const medium = typeof obj["@_medium"] === "string" ? (obj["@_medium"] as string) : "";
      const type = typeof obj["@_type"] === "string" ? (obj["@_type"] as string) : "";
      const isImage =
        medium === "image" ||
        type.toLowerCase().startsWith("image/") ||
        (!medium && !type); // feeds that omit both still often point at an image
      if (url && isImage) return url;
    }
  }

  // 3) media:thumbnail
  const mediaThumb = item["media:thumbnail"];
  const mtList = Array.isArray(mediaThumb)
    ? mediaThumb
    : mediaThumb
      ? [mediaThumb]
      : [];
  for (const mt of mtList) {
    if (mt && typeof mt === "object") {
      const obj = mt as Record<string, unknown>;
      const url = typeof obj["@_url"] === "string" ? (obj["@_url"] as string) : "";
      if (url) return url;
    }
    if (typeof mt === "string" && mt) return mt;
  }

  // 4) Inline <img src="..."> fallback — check the usual content fields.
  const fields: unknown[] = [
    item["content:encoded"],
    item.description,
    item.summary,
    item.content,
  ];
  for (const f of fields) {
    const text = readText(f);
    if (!text) continue;
    const match = text.match(/<img[^>]+src=["']([^"']+)["']/i);
    if (match && match[1]) return match[1];
  }

  return undefined;
}

type RssChannel = {
  item?: Array<Record<string, unknown>>;
};

type AtomFeed = {
  entry?: Array<Record<string, unknown>>;
};

/**
 * Fetch an RSS or Atom feed and normalise its entries to `RawItem[]`.
 *
 * Any network, parse, or content-type failure short-circuits to an
 * empty array — the aggregator is allowed to drop a broken source
 * silently. We log to `console.warn` so operators can still see which
 * source failed in server logs.
 */
export async function fetchFeed(url: string): Promise<RawItem[]> {
  let response: Response;
  try {
    response = await fetch(url, {
      headers: {
        Accept:
          "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8",
        "User-Agent":
          "OrthoGlobeNewsBot/1.0 (+https://orthoglobe.org/news)",
      },
      // Let the caller own caching via unstable_cache; fetch should
      // always hit the network when invoked.
      cache: "no-store",
    });
  } catch (error) {
    console.warn(`[news/rss] fetch failed for ${url}`, error);
    return [];
  }

  if (!response.ok) {
    console.warn(`[news/rss] ${url} returned ${response.status}`);
    return [];
  }

  const contentType = (response.headers.get("content-type") || "").toLowerCase();
  // Some sites serve XML as text/html or application/octet-stream, so we
  // don't hard-block on content-type — but we do bail out if the body
  // obviously isn't XML.
  const raw = await response.text();
  const trimmed = raw.trimStart();
  if (!trimmed.startsWith("<")) {
    console.warn(`[news/rss] ${url} did not return XML (content-type: ${contentType})`);
    return [];
  }

  let parsed: unknown;
  try {
    parsed = parser.parse(raw);
  } catch (error) {
    console.warn(`[news/rss] parse error for ${url}`, error);
    return [];
  }

  const doc = parsed as Record<string, unknown>;

  // RSS 2.0 path: rss > channel > item[]
  if (doc.rss && typeof doc.rss === "object") {
    const rss = doc.rss as Record<string, unknown>;
    const channel = (rss.channel ?? {}) as RssChannel;
    const items = Array.isArray(channel.item) ? channel.item : [];
    return items.map((item) => normaliseRssItem(item)).filter((i) => i.link);
  }

  // Atom path: feed > entry[]
  if (doc.feed && typeof doc.feed === "object") {
    const feed = doc.feed as AtomFeed;
    const entries = Array.isArray(feed.entry) ? feed.entry : [];
    return entries.map((entry) => normaliseAtomEntry(entry)).filter((i) => i.link);
  }

  console.warn(`[news/rss] ${url} had no <rss> or <feed> root`);
  return [];
}

function normaliseRssItem(item: Record<string, unknown>): RawItem {
  const title = readText(item.title);
  const link = readText(item.link);
  const pubDate = readText(item.pubDate) || readText(item["dc:date"]);
  // Prefer content:encoded for excerpt if available, else description.
  const contentEncoded = readText(item["content:encoded"]);
  const description = contentEncoded || readText(item.description);
  const author =
    readText(item["dc:creator"]) ||
    readText(item.author) ||
    readText(item.creator);
  return {
    title: title.trim(),
    link: link.trim(),
    pubDate: pubDate.trim(),
    description,
    author: author ? author.trim() : undefined,
    imageUrl: extractImageUrl(item),
  };
}

function normaliseAtomEntry(entry: Record<string, unknown>): RawItem {
  const title = readText(entry.title);
  const link = readAtomLink(entry.link);
  const pubDate = readText(entry.published) || readText(entry.updated);
  const summary = readText(entry.summary);
  const content = readText(entry.content);
  const description = content || summary;
  let author = "";
  const rawAuthor = entry.author;
  if (rawAuthor && typeof rawAuthor === "object" && !Array.isArray(rawAuthor)) {
    author = readText((rawAuthor as Record<string, unknown>).name);
  } else if (typeof rawAuthor === "string") {
    author = rawAuthor;
  }
  return {
    title: title.trim(),
    link: link.trim(),
    pubDate: pubDate.trim(),
    description,
    author: author ? author.trim() : undefined,
    imageUrl: extractImageUrl(entry),
  };
}
