// WorldMonitor Brief compose library.
//
// Pure helpers for producing the per-user brief envelope that the
// hosted magazine route (api/brief/*) + dashboard panel + future
// channels all consume. Shared between:
//   - scripts/seed-digest-notifications.mjs (the consolidated cron;
//     composes a brief for every user it's about to dispatch a
//     digest to, so the magazine URL can be injected into the
//     notification output).
//   - future tests + ad-hoc tools.
//
// Deliberately has NO top-level side effects: no env guards, no
// process.exit, no main(). Import anywhere.
//
// History: this file used to include a stand-alone Railway cron
// (`seed-brief-composer.mjs`). That path was retired in the
// consolidation PR — the digest cron now owns the compose+send
// pipeline so there is exactly one cron writing brief:{userId}:
// {issueDate} keys.

import {
  assembleStubbedBriefEnvelope,
  filterTopStories,
  issueDateInTz,
} from '../../shared/brief-filter.js';
import { classifyEphemeralLiveCoverage } from '../../shared/ephemeral-live-classifier.js';
import { sanitizeForPrompt, sanitizeHeadline } from '../../server/_shared/llm-sanitize.js';

// ── Rule dedupe (one brief per user, not per variant) ───────────────────────

const SENSITIVITY_RANK = { all: 0, high: 1, critical: 2 };

// Exported so the cron orchestration's two-pass winner walk
// (sortedDue / sortedAll) can sort each pass identically to how
// `groupEligibleRulesByUser` already orders candidates here. Kept as
// a same-shape function so callers can reuse it without re-deriving
// the priority key.
export function compareRules(a, b) {
  const aFull = a.variant === 'full' ? 0 : 1;
  const bFull = b.variant === 'full' ? 0 : 1;
  if (aFull !== bFull) return aFull - bFull;
  // Default missing sensitivity to 'high' (NOT 'all') so the rank
  // matches what compose/buildDigest/cache/log actually treat the
  // rule as. Otherwise a legacy undefined-sensitivity rule would be
  // ranked as the most-permissive 'all' and tried first, but compose
  // would then apply a 'high' filter — shipping a narrow brief while
  // an explicit 'all' rule for the same user is never tried.
  // See PR #3387 review (P2).
  const aRank = SENSITIVITY_RANK[a.sensitivity ?? 'high'] ?? 0;
  const bRank = SENSITIVITY_RANK[b.sensitivity ?? 'high'] ?? 0;
  if (aRank !== bRank) return aRank - bRank;
  return (a.updatedAt ?? 0) - (b.updatedAt ?? 0);
}

/**
 * Group eligible (not-opted-out) rules by userId with each user's
 * candidates sorted in preference order. Callers walk the candidate
 * list and take the first that produces non-empty stories — falls
 * back across variants cleanly.
 */
export function groupEligibleRulesByUser(rules) {
  const byUser = new Map();
  for (const rule of rules) {
    if (!rule || typeof rule.userId !== 'string') continue;
    if (rule.aiDigestEnabled === false) continue;
    const list = byUser.get(rule.userId);
    if (list) list.push(rule);
    else byUser.set(rule.userId, [rule]);
  }
  for (const list of byUser.values()) list.sort(compareRules);
  return byUser;
}

/**
 * @deprecated Kept for existing test imports. Prefer
 * groupEligibleRulesByUser + per-user fallback at call sites.
 */
export function dedupeRulesByUser(rules) {
  const out = [];
  for (const candidates of groupEligibleRulesByUser(rules).values()) {
    if (candidates.length > 0) out.push(candidates[0]);
  }
  return out;
}

// ── Failure gate ─────────────────────────────────────────────────────────────

/**
 * Decide whether the consolidated cron should exit non-zero because
 * the brief-write failure rate is structurally bad (not just a
 * transient blip). Denominator is ATTEMPTED writes, not eligible
 * users: skipped-empty users never reach the write path and must not
 * dilute the ratio.
 *
 * @param {{ success: number; failed: number; thresholdRatio?: number }} counters
 */
export function shouldExitNonZero({ success, failed, thresholdRatio = 0.05 }) {
  if (failed <= 0) return false;
  const attempted = success + failed;
  if (attempted <= 0) return false;
  const threshold = Math.max(1, Math.floor(attempted * thresholdRatio));
  return failed >= threshold;
}

// ── Insights fetch ───────────────────────────────────────────────────────────

/** Unwrap news:insights:v1 envelope and project the fields the brief needs. */
export function extractInsights(raw) {
  const data = raw?.data ?? raw;
  const topStories = Array.isArray(data?.topStories) ? data.topStories : [];
  const clusterCount = Number.isFinite(data?.clusterCount) ? data.clusterCount : topStories.length;
  const multiSourceCount = Number.isFinite(data?.multiSourceCount) ? data.multiSourceCount : 0;
  return {
    topStories,
    numbers: { clusters: clusterCount, multiSource: multiSourceCount },
  };
}

// ── Date + display helpers ───────────────────────────────────────────────────

const MONTH_NAMES = [
  'January', 'February', 'March', 'April', 'May', 'June',
  'July', 'August', 'September', 'October', 'November', 'December',
];

export function dateLongFromIso(iso) {
  const [y, m, d] = iso.split('-').map(Number);
  return `${d} ${MONTH_NAMES[m - 1]} ${y}`;
}

export function issueCodeFromIso(iso) {
  const [, m, d] = iso.split('-');
  return `${d}.${m}`;
}

export function localHourInTz(nowMs, timezone) {
  try {
    const fmt = new Intl.DateTimeFormat('en-US', {
      timeZone: timezone,
      hour: 'numeric',
      hour12: false,
    });
    const hour = fmt.formatToParts(new Date(nowMs)).find((p) => p.type === 'hour')?.value;
    const n = Number(hour);
    return Number.isFinite(n) ? n : 9;
  } catch {
    return 9;
  }
}

export function userDisplayNameFromId(userId) {
  // Clerk IDs look like "user_2abc…". Phase 3b will hydrate real
  // names via a Convex query; for now a generic placeholder so the
  // magazine's greeting reads naturally.
  void userId;
  return 'Reader';
}

// ── Compose a full brief for a single rule ──────────────────────────────────

// Cap on stories shown per user per brief.
//
// Default 12 — kept at the historical value because the offline sweep
// harness (scripts/sweep-topic-thresholds.mjs) showed bumping the cap
// to 16 against 2026-04-24 production replay data DROPPED visible
// quality at the active 0.45 threshold (visible_quality 0.916 → 0.716;
// positions 13-16 are mostly singletons or members of "should-separate"
// clusters at this threshold, so they dilute without helping adjacency).
//
// Env-tunable via DIGEST_MAX_STORIES_PER_USER so future sweep evidence
// (different threshold, different label set, different pool composition)
// can be acted on with a Railway env flip without a redeploy. Any
// invalid / non-positive value falls back to the 12 default.
//
// "Are we getting better" signal: re-run scripts/sweep-topic-thresholds.mjs
// with --cap N before flipping the env, and the daily
// scripts/brief-quality-report.mjs after.
function readMaxStoriesPerUser() {
  const raw = process.env.DIGEST_MAX_STORIES_PER_USER;
  if (raw == null || raw === '') return 12;
  const n = Number.parseInt(raw, 10);
  return Number.isFinite(n) && n > 0 ? n : 12;
}
// Exported so brief-llm.mjs (buildDigestPrompt + hashDigestInput) can
// slice to the same cap. Hard-coding `slice(0, 12)` there would mean
// the LLM prose only references the first 12 stories even when the
// brief envelope carries more — a quiet mismatch between what the
// reader sees as story cards vs the AI summary above them. Reviewer
// P1 on PR #3389.
export const MAX_STORIES_PER_USER = readMaxStoriesPerUser();

/**
 * Filter + assemble a BriefEnvelope for one alert rule from a
 * prebuilt upstream top-stories list (news:insights:v1 shape).
 *
 * @deprecated The live path is composeBriefFromDigestStories(), which
 *   reads from the same digest:accumulator pool as the email. This
 *   entry point is kept only for tests that stub a news:insights payload
 *   directly — real runs would ship a brief with a different story
 *   list than the email and should use the digest-stories path.
 *
 * @param {object} rule — enabled alertRule row
 * @param {{ topStories: unknown[]; numbers: { clusters: number; multiSource: number } }} insights
 * @param {{ nowMs: number }} [opts]
 */
export function composeBriefForRule(rule, insights, { nowMs = Date.now() } = {}) {
  // Default to 'high' (NOT 'all') for parity with composeBriefFromDigestStories,
  // buildDigest, the digestFor cache key, and the per-attempt log line.
  // See PR #3387 review (P2).
  const sensitivity = rule.sensitivity ?? 'high';
  const tz = rule.digestTimezone ?? 'UTC';
  const stories = filterTopStories({
    stories: insights.topStories,
    sensitivity,
    maxStories: MAX_STORIES_PER_USER,
  });
  if (stories.length === 0) return null;
  const issueDate = issueDateInTz(nowMs, tz);
  return assembleStubbedBriefEnvelope({
    user: { name: userDisplayNameFromId(rule.userId), tz },
    stories,
    issueDate,
    dateLong: dateLongFromIso(issueDate),
    issue: issueCodeFromIso(issueDate),
    insightsNumbers: insights.numbers,
    // Same nowMs as the rest of the envelope so the function stays
    // deterministic for a given input — tests + retries see identical
    // output.
    issuedAt: nowMs,
    localHour: localHourInTz(nowMs, tz),
  });
}

// ── Followed-country soft bias ──────────────────────────────────────────────

// Nominal multiplicative uplift the plan specifies (1.2–1.3×; midpoint
// 1.25). Exposed as a constant so U11's telemetry-driven tuning has a
// stable handle, and so the test suite can lock the headline figure
// against accidental regressions in adjacent code. Env-tunable via
// FOLLOWED_BIAS_MULTIPLIER for offline experiments — any non-finite
// or out-of-band value falls back to 1.25.
//
// IMPLEMENTATION NOTE: in practice the digest pool comes to us as an
// already-ranked LIST (no continuous relevance scalar), so a literal
// `score *= 1.25` on order-rank either does nothing (for adjacent
// pairs) or does too much (compounds across many positions). The
// behavior the plan actually wants — "followed-country stories
// cluster ahead of non-followed within their severity lane, preserving
// original order within each subgroup, with critical news immune" — is
// a stable tier sort: severityLane > isFollowed > originalIndex. The
// multiplier constant remains exported so U11 can correlate the tune
// knob with engagement lift even though the on-list mechanism uses
// the tier-sort form.
function readFollowedBiasMultiplier() {
  const raw = process.env.FOLLOWED_BIAS_MULTIPLIER;
  if (raw == null || raw === '') return 1.25;
  const n = Number.parseFloat(raw);
  if (!Number.isFinite(n) || n < 1 || n > 2) return 1.25;
  return n;
}
export const FOLLOWED_BIAS_MULTIPLIER = readFollowedBiasMultiplier();

// Severity priority lane. Critical stories MUST surface in the top N
// regardless of any user-side bias — this is the R10 hard contract
// ("soft bias, NOT a hard filter" in both directions: the bias never
// suppresses critical news, AND it never re-orders across severity
// lanes). Lane priority is encoded as the most-significant sort key
// so a non-followed critical thread always outranks a followed non-
// critical thread.
const SEVERITY_LANE_PRIORITY = {
  critical: 4,
  high: 3,
  moderate: 2, // upstream alias — same lane as 'medium'
  medium: 2,
  low: 1,
};

function severityLaneOf(threatLevel) {
  const sev = typeof threatLevel === 'string' ? threatLevel.toLowerCase() : '';
  return SEVERITY_LANE_PRIORITY[sev] ?? 0;
}

/**
 * Stable rerank that lifts followed-country stories within their
 * severity lane. Critical-severity stories stay critical-first;
 * non-followed criticals always outrank followed non-criticals
 * (memory: R10 hard contract — bias is soft, never elevates across
 * severity boundaries).
 *
 * Composite sort key (descending): severityLane, followedFlag,
 * inverse-originalIndex. Stable on ties so digest input order is
 * preserved when nothing in the watchlist matches.
 *
 * Pure helper — does not mutate the input. Returns a NEW array; or
 * the original ref unchanged on the cheap no-op paths so callers can
 * skip allocation when the watchlist is empty.
 *
 * @template {{ countryCode?: unknown, threatLevel?: unknown }} T
 * @param {T[]} stories
 * @param {Set<string>} followedSet  Uppercased ISO-2 codes.
 * @returns {T[]}
 */
export function reorderForFollowedBias(stories, followedSet) {
  if (!Array.isArray(stories) || stories.length === 0) return stories;
  if (!followedSet || followedSet.size === 0) return stories;
  // Cheap pre-check: if zero stories match the watchlist, bias is a
  // no-op — returning the input ref preserves the rare-case
  // perf win AND avoids reordering by severity lane in the
  // no-match case (which the digest cron's input order doesn't
  // guarantee — sort would silently re-sort even though the bias
  // contributed nothing). Behavior contract: bias only re-orders
  // when it has something to lift.
  let anyMatch = false;
  const annotated = stories.map((story, originalIndex) => {
    const lane = severityLaneOf(story?.threatLevel);
    const country = typeof story?.countryCode === 'string'
      ? story.countryCode.toUpperCase()
      : '';
    const followed = country.length > 0 && followedSet.has(country) ? 1 : 0;
    if (followed === 1) anyMatch = true;
    return { story, originalIndex, lane, followed };
  });
  if (!anyMatch) return stories;
  annotated.sort((a, b) => {
    if (a.lane !== b.lane) return b.lane - a.lane;       // critical > high > med > low
    if (a.followed !== b.followed) return b.followed - a.followed; // followed first
    return a.originalIndex - b.originalIndex;             // stable on ties
  });
  return annotated.map((a) => a.story);
}

// ── Compose from digest-accumulator stories (the live path) ─────────────────

// RSS titles routinely end with " - <Publisher>" / " | <Publisher>" /
// " — <Publisher>" (Google News normalised form + most major wires).
// Leaving the suffix in place means the brief headline reads like
// "... as Iran reimposes restrictions - AP News" instead of "... as
// Iran reimposes restrictions", and the source attribution underneath
// ends up duplicated. We strip the suffix ONLY when it matches the
// primarySource we're about to attribute anyway — so we never strip
// a real subtitle that happens to look like "foo - bar".
const HEADLINE_SUFFIX_RE_PART = /\s+[-\u2013\u2014|]\s+([^\s].*)$/;

/**
 * Wire-name vs feed-name match. Returns true when `tail` is a shorter
 * (or equal) word-boundary prefix of `publisher` — i.e. when the
 * headline ended with the wire-service short name (e.g. "Reuters")
 * but the configured publisher is the longer feed-name expansion
 * (e.g. "Reuters World" / "Reuters Politics"). Strict equality
 * (the v1 implementation) missed this case — observed live on the
 * May 13 brief: "Putin says Russia will deploy new Sarmat nuclear
 * missile this year - Reuters" had publisher "Reuters World" and the
 * strict-equality check passed the suffix to the magazine.
 *
 * The direction is asymmetric ON PURPOSE: we never accept the inverse
 * (publisher word-prefix of tail), because that case admits editorial
 * suffixes like "Story - AP News analysis" — the tail "AP News
 * analysis" extends the publisher "AP News" with an editorial word,
 * not a desk-name suffix, and stripping it would lose real content.
 *
 * Word-boundary requirement (trailing space) prevents "iran" matching
 * "iranian" — only space-delimited extensions ("Reuters" / "Reuters
 * World") succeed.
 *
 * @param {string} tail — already lowercased, trimmed
 * @param {string} publisher — already lowercased, trimmed
 * @returns {boolean}
 */
function isPublisherWordPrefix(tail, publisher) {
  if (tail === publisher) return true;
  if (tail.length >= publisher.length) return false;
  return publisher.startsWith(tail + ' ');
}

// ── Layer 2 helpers (publisher-naming variants) ───────────────────────────
//
// Layer 1's strict word-prefix test misses three structural classes of
// variant between the headline-suffix's publisher form and the configured
// `source` field. All three observed live on the May 15 brief:
//   1. Article insertion — tail "Bulletin of the Atomic Scientists"
//      vs source "Bulletin of Atomic Scientists" (the/no-the).
//   2. Trailing wire-suffix word — tail "BBC News" vs source "BBC".
//   3. Abbreviation ↔ long-form — tail "Department of Justice (.gov)"
//      vs source "DOJ".
//
// Layer 2 adds two source-aware paths after Layer 1:
//   Path 2a — `normalizePublisher` on both sides + the same asymmetric
//             prefix test (handles classes 1, 2, 3).
//   Path 2b — acronym-shape-gated initials equivalence using a separate
//             `tailForInitials` (handles class 3 when source is an
//             explicit ALL-CAPS acronym like DOJ/NPR/AP/BBC).
//
// No source-blind layer. Considered and rejected on Codex review —
// integrity risk (feed-controlled text could force user-visible
// truncation). See docs/plans/2026-05-15-001-fix-headline-suffix-strip-
// publisher-naming-variants-plan.md for the full rationale.

const ARTICLE_TOKENS = new Set(['the', 'a']);

// Wire-suffix tokens stripped ONLY from the trailing position of a
// normalised publisher. Iteratively from the end, never from leading
// or middle positions — stripping globally would corrupt names like
// "Daily Mail", "News Corp", "Press TV", "The Press Democrat".
const WIRE_SUFFIX_TOKENS = new Set([
  'news', 'online', 'press', 'wire', 'daily', 'weekly',
]);

// Connector words allowed inside a publisher-shape tail. Lowercase
// exact match (case-insensitive on the lowercased token).
const PUBLISHER_CONNECTOR_TOKENS = new Set([
  'of', 'the', 'and', 'du', 'de', 'le', 'la', 'el', 'al', 'in', 'for',
]);

// Title-Case token: starts with an uppercase letter, then word chars or
// apostrophe/hyphen. Accepts "BBC", "O'Reilly", "Al-Jazeera", "News".
const TITLE_CASE_TOKEN_RE = /^[A-Z][\w'-]*$/;
// Trailing domain paren: " (.gov)", " (.org)", " (.com)", " (.io)" etc.
const DOMAIN_PAREN_TRAILING_RE = /\s*\(\.\w{2,4}\)\s*$/;
// Explicit acronym shape on the ORIGINAL configured publisher field —
// 1-5 all-uppercase letters, no spaces. Matching on the unaltered
// field is what prevents Title-Case 4-char names like "Time"/"Wired"
// from accidentally activating the initials path.
const PUBLISHER_ACRONYM_RE = /^[A-Z]{1,5}$/;

/**
 * Normalise a publisher-name string for the asymmetric prefix test in
 * Path 2a. Lowercases, strips trailing domain paren, removes article
 * words from any position, removes wire-suffix words ONLY from the
 * trailing position iteratively, then strips non-alphanumerics per
 * token.
 *
 * Trailing-only suffix-strip is load-bearing: stripping `news` / `press`
 * / `daily` from leading or middle positions would corrupt names like
 * "Daily Mail", "News Corp", "Press TV", "The Press Democrat".
 *
 * Used ONLY in Path 2a. Path 2b's initials test uses tailForInitials()
 * which preserves wire-suffix words — the `Press` in "Associated Press"
 * must survive to count toward the AP initials.
 *
 * @param {unknown} s
 * @returns {string}
 */
function normalizePublisher(s) {
  if (typeof s !== 'string') return '';
  const trimmed = s.trim().toLowerCase();
  if (trimmed.length === 0) return '';
  const stripped = trimmed.replace(DOMAIN_PAREN_TRAILING_RE, '');
  let tokens = stripped
    .split(/\s+/)
    .filter((t) => t.length > 0 && !ARTICLE_TOKENS.has(t));
  while (tokens.length > 0 && WIRE_SUFFIX_TOKENS.has(tokens[tokens.length - 1])) {
    tokens.pop();
  }
  tokens = tokens
    .map((t) => t.replace(/[^a-z0-9]/g, ''))
    .filter((t) => t.length > 0);
  return tokens.join(' ');
}

/**
 * Tail normalisation for the initials path (Path 2b). Same as
 * normalizePublisher MINUS the wire-suffix strip — distinct because
 * "Associated Press" must keep `press` so initialsOf yields `ap`, not
 * just `a`. Reusing normalizePublisher here would corrupt the
 * Associated Press → AP / National Public Radio → NPR cases.
 *
 * @param {unknown} s
 * @returns {string}
 */
function tailForInitials(s) {
  if (typeof s !== 'string') return '';
  const trimmed = s.trim().toLowerCase();
  if (trimmed.length === 0) return '';
  const stripped = trimmed.replace(DOMAIN_PAREN_TRAILING_RE, '');
  const tokens = stripped
    .split(/\s+/)
    .filter((t) => t.length > 0 && !ARTICLE_TOKENS.has(t))
    .map((t) => t.replace(/[^a-z0-9]/g, ''))
    .filter((t) => t.length > 0);
  return tokens.join(' ');
}

/**
 * First letter of each whitespace-separated token, joined and
 * lowercased, alpha only. Returns '' for empty or all-non-alpha input.
 *
 * @param {string} s
 * @returns {string}
 */
function initialsOf(s) {
  if (typeof s !== 'string' || s.length === 0) return '';
  return s
    .split(/\s+/)
    .map((t) => (t.length > 0 ? t[0] : ''))
    .filter((c) => /^[a-z]$/i.test(c))
    .join('')
    .toLowerCase();
}

/**
 * "Looks like a publisher attribution" shape check on the original tail
 * (post domain-paren strip, pre other normalisation). Every token must
 * be either Title-Case (matches TITLE_CASE_TOKEN_RE) OR a permitted
 * lowercase connector. Token cap of 8 keeps the function focused on
 * filtering editorial fragments, not enforcing publisher length.
 *
 * Used in Path 2b as the second of two gates on the initials path; the
 * first gate is the all-uppercase acronym check on the original
 * publisher field. Together they block both editorial-text false
 * positives (lowercase tokens fail this gate) and ordinary-Title-Case-
 * publisher false positives (`Time`/`Wired` configured as source fail
 * the acronym gate).
 *
 * @param {unknown} s
 * @returns {boolean}
 */
function looksLikePublisherShape(s) {
  if (typeof s !== 'string') return false;
  const stripped = s.trim().replace(DOMAIN_PAREN_TRAILING_RE, '');
  if (stripped.length === 0) return false;
  const tokens = stripped.split(/\s+/);
  if (tokens.length === 0 || tokens.length > 8) return false;
  for (const tok of tokens) {
    if (TITLE_CASE_TOKEN_RE.test(tok)) continue;
    if (PUBLISHER_CONNECTOR_TOKENS.has(tok.toLowerCase())) continue;
    return false;
  }
  return true;
}

/**
 * @param {string} title
 * @param {string} publisher
 * @returns {string}
 */
export function stripHeadlineSuffix(title, publisher) {
  if (typeof title !== 'string' || title.length === 0) return '';
  if (typeof publisher !== 'string' || publisher.length === 0) return title.trim();
  const trimmed = title.trim();
  const m = trimmed.match(HEADLINE_SUFFIX_RE_PART);
  if (!m) return trimmed;
  const tail = m[1].trim();
  const stripped = trimmed.slice(0, m.index).trimEnd();
  // Layer 1: existing strict asymmetric word-prefix test (load-bearing
  // PR #3673 protection — tail must be a SHORTER prefix of publisher).
  // Stripping "AP News analysis" against "AP News" is REJECTED here,
  // and Layer 2 below preserves the same asymmetry.
  if (isPublisherWordPrefix(tail.toLowerCase(), publisher.toLowerCase())) {
    return stripped;
  }
  // Layer 2 — Path 2a: source-aware fuzzy match via normalised
  // asymmetric prefix. normalizePublisher strips articles globally and
  // wire-suffix words trailing-only, so "Bulletin of the Atomic
  // Scientists" matches "Bulletin of Atomic Scientists" and "BBC News"
  // matches "BBC" — without mangling "Daily Mail" or "News Corp".
  const normTail = normalizePublisher(tail);
  const normPub = normalizePublisher(publisher);
  if (
    normTail.length > 0
    && normPub.length > 0
    && isPublisherWordPrefix(normTail, normPub)
  ) {
    return stripped;
  }
  // Layer 2 — Path 2b: acronym-shape-gated initials equivalence. The
  // ORIGINAL publisher must match /^[A-Z]{1,5}$/ (DOJ, NPR, AP, BBC),
  // gated on the unaltered field as authored — Title-Case names like
  // "Time"/"Wired" do not opt in. The tail must also be Title-Case-or-
  // connector shaped, blocking lowercase editorial text. Initials use
  // tailForInitials (NOT normalizePublisher) so wire-suffix words like
  // `Press` in "Associated Press" survive to count toward `ap`.
  if (
    PUBLISHER_ACRONYM_RE.test(publisher)
    && looksLikePublisherShape(tail)
    && initialsOf(tailForInitials(tail)) === publisher.toLowerCase()
  ) {
    return stripped;
  }
  return trimmed;
}

// Editorial-format prefixes some feeds prepend to headlines. They tell
// the user nothing the magazine card doesn't already convey (every
// card has its own source line and body block), so they just dilute
// the headline. Conservative list — only patterns observed in
// production briefs (May 12 magazine page 16/18: "Video: Philippine
// senator flees ICC arrest..."). The trailing colon is REQUIRED so a
// real headline starting with the bare word "Video game regulator
// fines..." stays intact.
const HEADLINE_PREFIX_RE = /^(?:video|watch|live|photos?|gallery|listen|podcast|breaking|exclusive|opinion|analysis|update)\s*:\s*/i;

/**
 * Strip editorial-format prefixes like "Video: ", "Watch: ", "Live: ",
 * "Photos: ", "Breaking: " from the start of a headline.
 *
 * @param {string} title
 * @returns {string}
 */
export function stripHeadlinePrefix(title) {
  if (typeof title !== 'string' || title.length === 0) return '';
  return title.trim().replace(HEADLINE_PREFIX_RE, '').trimStart();
}

/**
 * Adapter for the SYNTHESIS boundary — distinct from
 * `digestStoryToUpstreamTopStory` (the compose-envelope boundary).
 *
 * The canonical synthesis (`generateDigestProse` via
 * `runSynthesisWithFallback` / `generateDigestProsePublic`) is handed
 * the raw `buildDigest` pool, whose stories carry
 * `{ title, severity, sources }`. But `buildDigestPrompt`,
 * `checkLeadGrounding`, and `hashDigestInput` all read
 * `{ headline, threatLevel, source, category, country }`. The
 * field-name mismatch meant every synthesis prompt rendered every
 * story line as `[h:hash] [] undefined — undefined · undefined ·
 * undefined` — the model got NO story content and confabulated the
 * lead/threads/signals wholesale (the May 12 / May 14 hallucinations),
 * and `checkLeadGrounding` saw empty headlines so the grounding gate
 * skipped every time. See plan
 * docs/plans/2026-05-14-001-fix-brief-pipeline-parity-grounding-opinion-plan.md
 * (F2, Phase 2).
 *
 * This is the SINGLE normalisation point — apply it once at each
 * synthesis call site, never patch the three readers individually.
 * The headline gets the same prefix/suffix cleanup the magazine
 * headline gets (so the lead grounds against the same text the
 * reader sees). Sanitisation closes the prompt-injection vector
 * (F8) — the digest-prose prompt carries the reader's profile
 * context, so an unsanitised hostile RSS `<title>` is a real risk.
 * The headline is normalised to a single line and then run through
 * `sanitizeHeadline` (structural delimiters only) — the full
 * `sanitizeForPrompt` would mangle legitimate news headlines whose
 * SUBJECT is an injection phrase, e.g. "Senator urges Trump to ignore
 * all previous instructions on tariffs". The single-line normalisation
 * closes the one gap structural-only sanitisation leaves: a multi-line
 * hostile `<title>` injecting a line-start role turn. The other
 * free-text fields (`source`, `category`, `country`) are metadata,
 * not headlines, so they get the full `sanitizeForPrompt`.
 * `threatLevel` is an enum and `hash` is a hex digest — neither is
 * sanitised.
 *
 * `country` defaults to `'Global'` (story:track:v1 carries no country
 * field; `digestStoryToUpstreamTopStory` + `filterTopStories` defaults
 * fill it). `category` IS carried on story:track:v1 (persisted by
 * buildStoryTrackHsetFields, defensive empty-string on missing), passed
 * through buildDigest's stories.push, and reaches this function as the
 * canonical lowercase EventCategory enum value (`'conflict'`, `'health'`,
 * `'diplomatic'`, …).
 *
 * Two fallback layers for pre-stamp residue rows where category is
 * absent — note that THIS function does NOT go through filterTopStories,
 * so its local guard is load-bearing, not redundant:
 *   1. **Local guard at the `category:` field write below**
 *      (`typeof s?.category === 'string' ? s.category : 'General'`).
 *      Fires for the synthesis-prompt path — the LLM prompt always
 *      receives a non-empty string even when the upstream `s` has no
 *      category field (rare in steady state; possible during the
 *      48h-accumulator post-deploy residue window per PR #3751).
 *   2. **filterTopStories' `asTrimmedString(raw.category) || 'General'`
 *      at `shared/brief-filter.js:384`.** Fires for the envelope/display
 *      path, which is a separate consumer that reads from the same
 *      upstream story shape but takes a different code route.
 * Removing either guard leaves the corresponding path exposed to
 * residue rows on deploy.
 *
 * Intentional case divergence between synthesis and display paths
 * (issue #3752):
 *   - **This function** feeds the LLM synthesis prompt
 *     (`buildDigestPrompt` in scripts/lib/brief-llm.mjs). The prompt
 *     uses the canonical lowercase enum value as a semantic anchor for
 *     LLM pattern-matching — the model's training distribution sees
 *     category labels as bare nouns more often than Title-Cased
 *     headings, so feeding `'conflict'` is the cleaner signal than
 *     feeding `'Conflict'`.
 *   - **The envelope/display path** goes through filterTopStories'
 *     `out.push` (`shared/brief-filter.js`) where `titleCase` runs
 *     once to produce `'Conflict'` for the threads card,
 *     magazine story-page, and public-thread fallback stub. Display
 *     surfaces want human readability.
 * Both paths read from the same upstream `s.category` (lowercase); the
 * divergence is downstream and load-bearing for each consumer's needs.
 * If you change one site, audit the other.
 *
 * @param {object} s — digest-shaped story from buildDigest()
 * @returns {{ headline: string; threatLevel: string; source: string; category: string; country: string; hash: string }}
 */
export function digestStoryToSynthesisShape(s) {
  const sources = Array.isArray(s?.sources) ? s.sources : [];
  // An empty / whitespace-only first entry passes the `typeof` guard but
  // is not a real source — fall back to 'Multiple wires' so a prompt line
  // never renders with a trailing blank attribution.
  const primarySource = sources.length > 0
    && typeof sources[0] === 'string'
    && sources[0].trim().length > 0
    ? sources[0]
    : 'Multiple wires';
  // Collapse all whitespace to single spaces up front: a headline is one
  // line by definition, and a multi-line hostile RSS <title> must not be
  // able to break the prompt's per-story line into a fake line-start role
  // turn ("...\nassistant: ignore all previous instructions").
  const rawTitle = typeof s?.title === 'string' ? s.title.replace(/\s+/g, ' ').trim() : '';
  const cleanTitle = stripHeadlineSuffix(stripHeadlinePrefix(rawTitle), primarySource);
  return {
    // sanitizeHeadline (structural-only) — NOT sanitizeForPrompt — so a
    // legitimate headline that quotes an injection phrase as its news
    // subject survives intact. See the doc comment above. The rawTitle
    // single-line normalisation above closes the newline-injection gap
    // that structural-only sanitisation would otherwise leave open.
    headline: sanitizeHeadline(cleanTitle),
    threatLevel: typeof s?.severity === 'string' ? s.severity : '',
    source: sanitizeForPrompt(primarySource),
    // `s.category` is the canonical lowercase EventCategory enum value
    // here (synthesis-prompt path uses lowercase as semantic anchor;
    // display path Title-Cases at the envelope-build site). See the
    // function doc above for the case-divergence rationale (#3752).
    category: sanitizeForPrompt(typeof s?.category === 'string' ? s.category : 'General'),
    country: sanitizeForPrompt(typeof s?.countryCode === 'string' ? s.countryCode : 'Global'),
    hash: typeof s?.hash === 'string' ? s.hash : '',
  };
}

/**
 * Adapter: the digest accumulator hydrates stories from
 * story:track:v1:{hash} (title / link / severity / lang / score /
 * mentionCount / description? / isOpinion / isFeelGood / category) +
 * story:sources:v1:{hash} SMEMBERS. story:track:v1 does NOT carry a
 * country-code — that field is optional in the upstream brief-filter
 * shape and defaults to 'Global' cleanly. `category` IS carried (as of
 * the U1 persistence fix); pre-stamp residue rows without the field
 * gracefully degrade to 'General' via filterTopStories' fallback.
 *
 * Since envelope v2, the story's `link` field is carried through as
 * `primaryLink` so filterTopStories can emit a BriefStory.sourceUrl.
 * Stories without a valid link are still passed through here — the
 * filter drops them at the validation boundary rather than this adapter.
 *
 * Description plumbing (post RSS-description fix, 2026-04-24):
 *   When the ingested story:track row carries a cleaned RSS description,
 *   it rides here as `s.description` and becomes the brief's baseline
 *   description. When absent (old rows inside the 48h bleed, or feeds
 *   without a description), we fall back to the cleaned headline —
 *   preserving today's behavior and letting Phase 3b's LLM enrichment
 *   still operate over something, not nothing.
 *
 * @param {object} s — digest-shaped story from buildDigest()
 */
function digestStoryToUpstreamTopStory(s) {
  const sources = Array.isArray(s?.sources) ? s.sources : [];
  const primarySource = sources.length > 0 ? sources[0] : 'Multiple wires';
  const rawTitle = typeof s?.title === 'string' ? s.title : '';
  // Two-stage cleanup: strip editorial-format prefix first ("Video:",
  // "Watch:", "Breaking:") then publisher suffix (" - Reuters",
  // "| AP News"). Order matters because some headlines have both:
  // "Video: Philippine senator flees ICC arrest - Al Jazeera" should
  // become "Philippine senator flees ICC arrest".
  const cleanTitle = stripHeadlineSuffix(stripHeadlinePrefix(rawTitle), primarySource);
  const rawDescription = typeof s?.description === 'string' ? s.description.trim() : '';
  const primaryLink = typeof s?.link === 'string' ? s.link : undefined;
  return {
    primaryTitle: cleanTitle,
    // When upstream persists a real RSS description (via story:track:v1
    // post-fix), forward it; otherwise fall back to the cleaned headline
    // so downstream consumers (brief filter, Phase 3b LLM) always have
    // something to ground on.
    description: rawDescription || cleanTitle,
    primarySource,
    primaryLink,
    // Preserve the raw-title verdict across display cleanup. Without this,
    // "Watch: Press conference live" would be stripped to "Press conference
    // live" before filterTopStories can tell it was an expiring viewing invite.
    isEphemeralLiveCoverage: classifyEphemeralLiveCoverage({
      title: rawTitle,
      link: primaryLink,
      description: rawDescription,
    }),
    threatLevel: s?.severity,
    importanceScore: Number.isFinite(Number(s?.currentScore)) ? Number(s.currentScore) : undefined,
    // Transient coherence signal from story:track:v1. Not written into
    // BriefStory; shared/brief-filter.js consumes it before envelope
    // assembly to keep an entity-corroborated flashpoint-diplomacy lead
    // aligned with the first rendered card when the LLM ranked it first.
    entityCorroborationCount: Number.isFinite(Number(s?.entityCorroborationCount))
      ? Number(s.entityCorroborationCount)
      : 0,
    // `category` IS carried on story:track:v1 (persisted by
    // buildStoryTrackHsetFields, passed through buildDigest's stories.push).
    // Pre-stamp residue rows missing the field fall back to 'General' via
    // filterTopStories' `|| 'General'` default. `countryCode` is NOT
    // carried; falls back to 'Global' the same way.
    category: typeof s?.category === 'string' ? s.category : undefined,
    countryCode: typeof s?.countryCode === 'string' ? s.countryCode : undefined,
    // Stable digest story hash. Carried through so:
    //   (a) the canonical synthesis prompt can emit `rankedStoryHashes`
    //       referencing each story by hash (not position, not title),
    //   (b) `filterTopStories` can use the model's order as the final
    //       tie-breaker after deterministic severity/topic-block mass
    //       and score, before applying the MAX_STORIES_PER_USER cap.
    // Falls back to titleHash when the digest path didn't materialise
    // a primary `hash` (rare; shape varies across producer versions).
    hash: typeof s?.hash === 'string' && s.hash.length > 0
      ? s.hash
      : (typeof s?.titleHash === 'string' ? s.titleHash : undefined),
    // Sprint 1 / U3: canonical cluster-rep hash threaded into
    // BriefStory.clusterId via filterTopStories. For multi-story
    // clusters, materializeCluster (in brief-dedup-jaccard.mjs) sets
    // `mergedHashes[]` on the rep — `mergedHashes[0]` is the
    // deterministic cluster identity (sort: score DESC, mentionCount
    // DESC, hash ASC), shared by every member that maps back to this
    // rep. For singleton clusters (no clustering pass, or one-member
    // result) `mergedHashes` is absent — fall back to the rep's own
    // hash so singletons satisfy the plan invariant "clusterId equals
    // the story's own hash" naturally.
    clusterRepHash: Array.isArray(s?.mergedHashes) && s.mergedHashes.length > 0
      && typeof s.mergedHashes[0] === 'string' && s.mergedHashes[0].length > 0
      ? s.mergedHashes[0]
      : (typeof s?.hash === 'string' && s.hash.length > 0 ? s.hash : undefined),
    // Transient topic-ordering metadata from groupTopicsPostDedup.
    // filterTopStories consumes these before writing BriefStory; they
    // are not part of the persisted envelope schema.
    briefTopicId: typeof s?.briefTopicId === 'string' && s.briefTopicId.length > 0
      ? s.briefTopicId
      : undefined,
    briefTopicSize: Number.isFinite(Number(s?.briefTopicSize)) ? Number(s.briefTopicSize) : undefined,
    briefTopicMaxScore: Number.isFinite(Number(s?.briefTopicMaxScore)) ? Number(s.briefTopicMaxScore) : undefined,
  };
}

/**
 * Compose a BriefEnvelope from a per-rule digest-accumulator pool
 * (same stories the email digest uses), plus global insights numbers
 * for the stats page.
 *
 * Returns null when no story survives the sensitivity filter — caller
 * falls back to another variant or skips the user.
 *
 * Pure / synchronous. The cron orchestration layer pre-resolves the
 * canonical synthesis (`exec` from `generateDigestProse`) and the
 * non-personalised `publicLead` (`generateDigestProsePublic`) and
 * passes them in via `opts.synthesis` — this module performs no LLM
 * I/O.
 *
 * @param {object} rule — enabled alertRule row
 * @param {unknown[]} digestStories — output of buildDigest(rule, windowStart)
 * @param {{ clusters: number; multiSource: number }} insightsNumbers
 * @param {{
 *   nowMs?: number,
 *   onDrop?: import('../../shared/brief-filter.js').DropMetricsFn,
 *   onOrder?: import('../../shared/brief-filter.js').OrderMetricsFn,
 *   synthesis?: {
 *     lead?: string,
 *     threads?: Array<{ tag: string, teaser: string }>,
 *     signals?: string[],
 *     rankedStoryHashes?: string[],
 *     publicLead?: string,
 *     publicSignals?: string[],
 *     publicThreads?: Array<{ tag: string, teaser: string }>,
 *   },
 *   followedCountries?: string[],
 * }} [opts]
 *   `onDrop` is forwarded to filterTopStories so the seeder can
 *   aggregate per-user filter-drop counts without this module knowing
 *   how they are reported.
 *   `onOrder` is forwarded the same way for aggregate-only ordering
 *   telemetry; the callback receives no raw headlines.
 *   `synthesis` (when provided) substitutes envelope.digest.lead /
 *   threads / signals / publicLead with the canonical synthesis from
 *   the orchestration layer. `synthesis.rankedStoryHashes` is passed to
 *   the filter as a tie-breaker after severity/topic-cluster ordering,
 *   before applying the cap.
 *   `followedCountries` (PR C / U10) clusters matching stories ahead
 *   of non-followed stories within the same severity lane while
 *   preserving original order inside each subgroup. Critical-severity
 *   stories always surface regardless of bias (R10 hard contract).
 *   Caller is expected to already have applied any free-tier clamp (memory:
 *   `paywalled-feature-needs-three-layer-entitlement-gate`).
 *   When `synthesis.rankedStoryHashes` is supplied, that LLM-driven
 *   editorial ranking takes priority over the followed-country bias.
 */
export function composeBriefFromDigestStories(rule, digestStories, insightsNumbers, { nowMs = Date.now(), onDrop, onOrder, synthesis, followedCountries } = {}) {
  if (!Array.isArray(digestStories) || digestStories.length === 0) return null;
  // Default to 'high' (NOT 'all') for undefined sensitivity, aligning
  // with buildDigest at scripts/seed-digest-notifications.mjs:392 and
  // the digestFor cache key. The live cron path pre-filters the pool
  // to {critical, high}, so this default is a no-op for production
  // calls — but a non-prefiltered caller with undefined sensitivity
  // would otherwise silently widen to {medium, low} stories while the
  // operator log labels the attempt as 'high', misleading telemetry.
  // See PR #3387 review (P2) and Defect 2 / Solution 1 in
  // docs/plans/2026-04-24-004-fix-brief-topic-adjacency-defects-plan.md.
  const sensitivity = rule.sensitivity ?? 'high';
  const tz = rule.digestTimezone ?? 'UTC';
  const upstreamLike = digestStories.map(digestStoryToUpstreamTopStory);

  // PR C / U10: lift followed-country stories within their severity
  // lane BEFORE filterTopStories runs its rankedStoryHashes sort. When
  // synthesis.rankedStoryHashes is supplied, that LLM editorial
  // ranking wins (filterTopStories' applyRankedOrder runs after this
  // and re-orders by hash); when absent, the followed-country bias
  // sets the input order. Critical-severity stories always sort first
  // — the SEVERITY_LANE_MULTIPLIER spread (1_000_000 vs 1_000) makes
  // it impossible for any FOLLOWED_BIAS_MULTIPLIER inside [1, 2] to
  // promote a non-critical story over a critical one (R10 hard
  // contract: bias is soft, never displaces critical news).
  const followedSet = Array.isArray(followedCountries) && followedCountries.length > 0
    ? new Set(followedCountries.filter((c) => typeof c === 'string').map((c) => c.toUpperCase()))
    : null;
  const orderedUpstream = followedSet ? reorderForFollowedBias(upstreamLike, followedSet) : upstreamLike;

  const stories = filterTopStories({
    stories: orderedUpstream,
    sensitivity,
    maxStories: MAX_STORIES_PER_USER,
    onDrop,
    onOrder,
    rankedStoryHashes: synthesis?.rankedStoryHashes,
  });
  if (stories.length === 0) return null;
  const issueDate = issueDateInTz(nowMs, tz);
  const envelope = assembleStubbedBriefEnvelope({
    user: { name: userDisplayNameFromId(rule.userId), tz },
    stories,
    issueDate,
    dateLong: dateLongFromIso(issueDate),
    issue: issueCodeFromIso(issueDate),
    insightsNumbers,
    issuedAt: nowMs,
    localHour: localHourInTz(nowMs, tz),
  });
  // Splice canonical synthesis into the envelope's digest. Done as a
  // shallow merge so the assembleStubbedBriefEnvelope path stays the
  // single source for greeting/numbers/threads-default. We only
  // override the LLM-driven fields when the orchestrator supplied
  // them; missing fields fall back to the stub for graceful
  // degradation when synthesis fails.
  if (synthesis && envelope?.data?.digest) {
    if (typeof synthesis.lead === 'string' && synthesis.lead.length > 0) {
      envelope.data.digest.lead = synthesis.lead;
    }
    if (Array.isArray(synthesis.threads) && synthesis.threads.length > 0) {
      envelope.data.digest.threads = synthesis.threads;
    }
    if (Array.isArray(synthesis.signals)) {
      envelope.data.digest.signals = synthesis.signals;
    }
    if (typeof synthesis.publicLead === 'string' && synthesis.publicLead.length > 0) {
      envelope.data.digest.publicLead = synthesis.publicLead;
    }
    // Public signals/threads are non-personalised siblings produced by
    // generateDigestProsePublic. Captured separately from the
    // personalised signals/threads above so the share-URL renderer
    // never has to choose between leaking and omitting a whole page.
    if (Array.isArray(synthesis.publicSignals) && synthesis.publicSignals.length > 0) {
      envelope.data.digest.publicSignals = synthesis.publicSignals;
    }
    if (Array.isArray(synthesis.publicThreads) && synthesis.publicThreads.length > 0) {
      envelope.data.digest.publicThreads = synthesis.publicThreads;
    }
  }
  return envelope;
}

/**
 * Derive the rendered `digest.threads` from the FINAL ordered story
 * walk (plan F7 / Phase 6).
 *
 * The LLM still emits `synthesis.threads` — that stays the haystack
 * `checkLeadGrounding` inspects — but the rendered "On The Desk"
 * threads page is no longer an independent editorial judgment that can
 * disagree with the story walk. On 2026-05-13 the threads page listed
 * topics in an order the story walk did not follow, and a story
 * (hantavirus) was covered by no thread at all. Here threads are one
 * per topic-cluster, in the EXACT order the stories render.
 *
 * `orderBriefCandidates` (shared/brief-filter.js) emits same-cluster
 * stories contiguously, so a consecutive-run group on `clusterId`
 * reproduces the walk's block order without needing the transient
 * topic key (which is deliberately not written onto BriefStory).
 *
 * `tag` is the cluster's category; `teaser` is the cluster's lead
 * (first, highest-ranked) story's `description`. Call this AFTER
 * `enrichBriefEnvelopeWithLLM` so the teaser is the LLM editorial
 * sentence; the filter-stage `description = rawDescription || headline`
 * fallback guarantees a non-empty string either way.
 *
 * @param {Array<{ clusterId?: string; category?: string; headline?: string; description?: string }>} stories
 *   the FINAL ordered `envelope.data.stories[]`
 * @returns {Array<{ tag: string; teaser: string }>}
 */
export function deriveThreadsFromOrderedStories(stories) {
  if (!Array.isArray(stories)) return [];
  /** @type {Array<{ tag: string; teaser: string }>} */
  const threads = [];
  let lastClusterId;
  let started = false;
  for (const s of stories) {
    const clusterId = typeof s?.clusterId === 'string' && s.clusterId.length > 0
      ? s.clusterId
      : null;
    // New cluster boundary → this story leads a new thread. A null
    // clusterId (defensive — the filter guarantees a non-empty one)
    // never coalesces: each such story becomes its own thread.
    const isBoundary = !started || clusterId === null || clusterId !== lastClusterId;
    if (!isBoundary) continue;
    const tag = typeof s?.category === 'string' && s.category.trim().length > 0
      ? s.category.trim()
      : 'General';
    const description = typeof s?.description === 'string' ? s.description.trim() : '';
    const headline = typeof s?.headline === 'string' ? s.headline.trim() : '';
    const teaser = description.length > 0 ? description : headline;
    // Skip a cluster lead with no usable text rather than emit an
    // invalid `{tag, teaser:''}` the renderer's assert would reject.
    if (teaser.length > 0) threads.push({ tag, teaser });
    lastClusterId = clusterId;
    started = true;
  }
  return threads;
}
