import { createHash } from "node:crypto";
import { unstable_cache } from "next/cache";

import { fetchFeed, stripHtml } from "./rss";
import { NEWS_SOURCES } from "./sources";
import type { NewsItem, NewsSource, RawItem } from "./types";

/**
 * News aggregator with on-disk / in-memory caching.
 *
 * Responsibilities:
 *   1. Fan out to every source in `NEWS_SOURCES` in parallel.
 *   2. Normalise each `RawItem` into a `NewsItem` with a deterministic id.
 *   3. Merge, sort newest-first, and dedupe by canonical outbound link.
 *   4. Wrap in `unstable_cache` so we don't hammer publishers — the /news
 *      page is safe to serve from cache for 6 hours between refreshes.
 *
 * Refresh strategy:
 *   - Passive: `revalidate: 21600` seconds = 6 hours.
 *   - Active: `POST /api/refresh/news` calls `revalidateTag("news")`.
 *
 * Exports:
 *   - `getCachedNewsWithMeta()` — canonical entry point that returns
 *     `{ items, fetchedAt }` so the UI can render a last-refreshed marker.
 *   - `getCachedNews()` — thin wrapper that returns just `NewsItem[]`.
 */

const NEWS_CACHE_KEY = ["news", "aggregated", "v4"] as const;
const NEWS_CACHE_TAG = "news";
const NEWS_REVALIDATE_SECONDS = 60 * 60 * 6; // 6 hours

export type NewsCacheEntry = {
  items: NewsItem[];
  /** ISO-8601 timestamp of when the cache entry was filled. */
  fetchedAt: string;
};

/**
 * Generate a stable, URL-safe id for a news item.
 *
 * We hash `sourceId\n${link}` with SHA-256 then base64url-encode the
 * first 16 bytes. That's 22 characters — short enough for a pretty URL,
 * long enough that collisions are statistically impossible for the size
 * of our corpus. Ids are deterministic across requests so they are safe
 * to use in `generateStaticParams` and as React keys.
 */
export function makeNewsItemId(sourceId: string, link: string): string {
  const hash = createHash("sha256")
    .update(`${sourceId}\n${link}`)
    .digest();
  const slice = hash.subarray(0, 16);
  return slice
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

/**
 * Parse a feed's date field into an ISO string + year pair.
 *
 * Feeds use RFC 822, ISO 8601, and occasionally freeform strings.
 * `Date` in Node handles the first two; when it fails we fall back to
 * "now" so the item still shows up at the top of the list rather than
 * being silently dropped.
 */
function parsePubDate(raw: string): { iso: string; year: number } {
  if (raw) {
    const d = new Date(raw);
    if (!Number.isNaN(d.getTime())) {
      return { iso: d.toISOString(), year: d.getUTCFullYear() };
    }
  }
  const now = new Date();
  return { iso: now.toISOString(), year: now.getUTCFullYear() };
}

function normalise(source: NewsSource, raw: RawItem): NewsItem | null {
  if (!raw.link || !raw.title) return null;
  const { iso, year } = parsePubDate(raw.pubDate);
  return {
    id: makeNewsItemId(source.id, raw.link),
    sourceId: source.id,
    sourceName: source.name,
    title: raw.title,
    link: raw.link,
    pubDate: iso,
    year,
    excerpt: stripHtml(raw.description, 220),
    author: raw.author,
    imageUrl: raw.imageUrl,
  };
}

async function fetchSource(source: NewsSource): Promise<NewsItem[]> {
  const raws = await fetchFeed(source.feedUrl);
  const normalised: NewsItem[] = [];
  for (const raw of raws) {
    const item = normalise(source, raw);
    if (item) normalised.push(item);
  }
  return normalised;
}

/**
 * Internal uncached aggregator. Public callers should use
 * `getCachedNewsWithMeta()` or `getCachedNews()`.
 */
async function aggregateUncached(): Promise<NewsItem[]> {
  const results = await Promise.allSettled(
    NEWS_SOURCES.map((source) => fetchSource(source)),
  );

  const items: NewsItem[] = [];
  for (let i = 0; i < results.length; i += 1) {
    const result = results[i];
    const source = NEWS_SOURCES[i];
    if (result.status === "fulfilled") {
      items.push(...result.value);
    } else {
      console.warn(
        `[news/cache] source "${source.id}" failed`,
        result.reason,
      );
    }
  }

  // Dedupe by canonical outbound link. If the same article shows up in
  // two feeds (unusual but possible with syndicated content), we keep
  // the first occurrence and drop the rest.
  const seen = new Set<string>();
  const deduped: NewsItem[] = [];
  for (const item of items) {
    const key = item.link.toLowerCase();
    if (seen.has(key)) continue;
    seen.add(key);
    deduped.push(item);
  }

  // Newest first. `pubDate` is already ISO-8601 so string compare works,
  // but we go via Date to be explicit about intent.
  deduped.sort(
    (a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime(),
  );

  return deduped;
}

/**
 * Internal cache that only stores non-empty responses. Throwing on empty
 * stops `unstable_cache` from persisting a bad result — otherwise a single
 * failed aggregation would lock the page on an empty state for 6 hours.
 */
const getCachedNewsInner: () => Promise<NewsCacheEntry> = unstable_cache(
  async () => {
    const fetchedAt = new Date().toISOString();
    const items = await aggregateUncached();
    if (items.length === 0) {
      throw new Error("news aggregator returned zero items");
    }
    return { items, fetchedAt };
  },
  [...NEWS_CACHE_KEY],
  {
    tags: [NEWS_CACHE_TAG],
    revalidate: NEWS_REVALIDATE_SECONDS,
  },
);

/**
 * Canonical cached aggregator returning `{ items, fetchedAt }`.
 *
 * On total failure (every source rejects) we swallow the error here and
 * resolve to an empty list. That empty list is NOT cached — the next
 * request will retry the upstream feeds instead of being stuck.
 */
export async function getCachedNewsWithMeta(): Promise<NewsCacheEntry> {
  try {
    return await getCachedNewsInner();
  } catch (error) {
    console.warn(
      "[news/cache] aggregator failed, returning empty list (NOT cached):",
      error,
    );
    return { items: [] as NewsItem[], fetchedAt: new Date().toISOString() };
  }
}

// Thin wrapper returning just the aggregated items. Extend
// `getCachedNewsWithMeta()` if callers need the fetchedAt marker.
export async function getCachedNews(): Promise<NewsItem[]> {
  const { items } = await getCachedNewsWithMeta();
  return items;
}

export { NEWS_CACHE_TAG };
