/**
 * SEO Section Synthesizer
 * ────────────────────────
 * Parallel two-call architecture:
 *   Call A (Sonnet + prompt caching): narrative — executive summary, findings, priority actions
 *   Call B (Haiku): structured — methodology, industry framework, quick wins
 * Both run via Promise.all, completing in ~45-55s total vs the prior ~90s monolith.
 *
 * Internal module — model/provider names must not appear in user-facing contexts.
 */

import { jsonrepair } from "jsonrepair";
import type {
  AuditInput,
  SeoFindings,
  SeoSection,
  BehavioralOverlay,
  AiSearchPerformance,
  IndustryClassification,
  AuditMethodology,
} from "../types";
import { AuditSynthesisError } from "../types";
import type { ContentSample } from "../scrapers/firecrawl-content";
import { formatContentForPrompt } from "../scrapers/firecrawl-content";
import { normUrl } from "../url-utils";
import { classifyIndustry } from "./industry-classifier";
import type { IndustryContext } from "./industry-classifier";
import type { SerpAnalysis } from "../data-sources/serp-analysis";

// ─── Typed synthesizer errors ─────────────────────────────────────────────────

export class SynthesizerTimeoutError extends Error {
  constructor(public readonly underlyingError: unknown, public readonly attempts: number) {
    super(`Synthesizer timeout after ${attempts} attempt(s)`);
    this.name = "SynthesizerTimeoutError";
  }
}

export class SynthesizerError extends Error {
  constructor(public readonly underlyingError: unknown) {
    super("Synthesizer error: " + String(underlyingError));
    this.name = "SynthesizerError";
  }
}

// ─── Model constants ──────────────────────────────────────────────────────────

const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY?.trim() ?? "";
const NARRATIVE_MODEL = "claude-sonnet-4-6";
const STRUCTURED_MODEL = "claude-haiku-4-5-20251001";
const NARRATIVE_MAX_TOKENS = 14000; // raised to 14000: executiveBrief structured output adds ~2k tokens on top of existing 12k budget
const STRUCTURED_MAX_TOKENS = 1500;
const TEMPERATURE = 0.3;
const NARRATIVE_TIMEOUT_MS = 280_000;
const STRUCTURED_TIMEOUT_MS = 45_000;
// Budget: data layer ~90-110s + max(narrative 280s, structured 45s) parallel = ~390s worst case, well under 800s Vercel maxDuration ceiling. Narrative timeout sized for 14K max output tokens at Sonnet median throughput of 75 tok/sec (14K / 75 = 187s, well under 280s).

// ─── Industry classification ──────────────────────────────────────────────────

const VALID_INDUSTRIES = [
  "higher-ed",
  "healthcare-medical",
  "healthcare-dental",
  "b2b-saas",
  "ecommerce",
  "local-services",
  "professional-services",
  "finance",
  "legal",
  "real-estate",
  "nonprofit",
  "hospitality",
  "other",
] as const;

type Industry = (typeof VALID_INDUSTRIES)[number];

const INDUSTRY_LENSES: Record<Industry, string> = {
  "healthcare-medical":
    "Patient acquisition journey, clinical authority signals, appointment conversion. Scope all SEO outcomes to search visibility and traffic — never imply clinical outcomes.",
  "healthcare-dental":
    "Patient acquisition, dental procedure discoverability, local SEO dominance, appointment booking conversion.",
  "higher-ed":
    "Enrollment funnel, prospective student search intent, program and degree discoverability, application conversion.",
  "b2b-saas":
    "MQL pipeline, evaluation-stage content, bottom-of-funnel search intent, free trial or demo conversion.",
  ecommerce:
    "Product discovery, category architecture, transactional intent, conversion rate from organic search.",
  "local-services":
    "Local search visibility, proximity and 'near me' intent, Google Business Profile alignment, review signals.",
  "professional-services":
    "Expertise and authority signals, lead generation from organic search, trust-building content.",
  finance:
    "Trust and compliance signals, educational content authority, lead generation from informational search.",
  legal:
    "Practice area discoverability, local attorney search, case type intent, consultation conversion.",
  "real-estate":
    "Property search intent, local market authority, listing discoverability, agent trust signals.",
  nonprofit:
    "Cause awareness, donor acquisition from search, volunteer engagement, mission storytelling.",
  hospitality:
    "Booking intent, location-based search, amenity discoverability, direct booking conversion.",
  other:
    "Organic traffic acquisition, authority building, search-to-conversion optimization.",
};

/**
 * Classifies a brand's industry using a lightweight AI call.
 * Returns "other" if classification fails or produces an unrecognised value.
 *
 * @deprecated Use classifyIndustry() from industry-classifier.ts for full IndustryContext.
 * Kept for backward compatibility — delegates to classifyIndustry() internally.
 */
export async function inferIndustry(
  brandName: string,
  website: string,
  contentSamples: ContentSample[],
): Promise<string> {
  const ctx = await classifyIndustry(brandName, website, contentSamples).catch(() => null);
  return ctx?.industry ?? "other";
}

// ─── Enrichment input type ────────────────────────────────────────────────────

/** Optional third-party enrichment data (backlinks + SERP) passed into synthesis. */
export interface AuditEnrichment {
  authority?: {
    domainRank: number | null;
    totalBacklinks: number | null;
    referringDomains: number | null;
    spamScore?: number | null;
    brokenBacklinks?: number | null;
  };
  serp?: Array<{
    keyword: string;
    position: number | null;
    url: string | null;
    title: string | null;
  }>;
  // New fields:
  serpAnalysis?: SerpAnalysis;
  industryContext?: IndustryContext;
}

// ─── Findings sanitization ────────────────────────────────────────────────────
// Zero out total/denominator fields so the model cannot construct "N of M pages"
// fractions. Individual issue counts (missing, tooLong, etc.) are kept because
// they describe problem severity without revealing crawl scope.

function sanitizeFindings(f: SeoFindings): SeoFindings {
  return {
    ...f,
    siteSize: { ...f.siteSize, totalPages: 0 },
    metaTitles: { ...f.metaTitles, total: 0 },
    metaDescriptions: { ...f.metaDescriptions, total: 0 },
    content: { ...f.content, totalContentPages: 0 },
  };
}

// ─── Narrative system prompt (Sonnet — all consulting prose) ──────────────────

function buildNarrativeSystemPrompt(
  hasFirstParty: boolean,
  industry: string,
  degradedMode?: boolean,
  industryContext?: IndustryContext,
): string {
  const industryLens = INDUSTRY_LENSES[industry as Industry] ?? INDUSTRY_LENSES["other"];
  const schemaPrioritiesNote = industryContext?.schemaPriorities.length
    ? ` Priority schema types for this vertical: ${industryContext.schemaPriorities.join(", ")}.`
    : "";

  const findingSchema = hasFirstParty
    ? `    {
      "rank": 1,
      "title": "Short descriptor headline (5-8 words)",
      "priority": "critical",
      "whatsWorking": "1-2 sentences of genuine specific positive observation citing pages or patterns.",
      "whereItFallsShort": "1-3 sentences. MUST weave 2+ data sources. Lead with specific number, URL, or unexpected pattern.",
      "recommendedNextSteps": [
        "Full action sentence with actual artifact (JSON-LD, copy, headline) where applicable.",
        "Full action sentence with specifics.",
        "Full action sentence with specifics."
      ],
      "affectedUrls": ["https://example.com/specific-page-from-audit-data"]
    }`
    : `    {
      "rank": 1,
      "title": "Short descriptor headline (5-8 words)",
      "priority": "critical",
      "whatsWorking": "1-2 sentences of genuine specific positive observation citing pages or patterns.",
      "whereItFallsShort": "1-3 sentences. MUST weave 2+ data sources. Lead with specific number, URL, or unexpected pattern.",
      "recommendedNextSteps": [
        "Full action sentence with actual artifact (JSON-LD, copy, headline) where applicable.",
        "Full action sentence with specifics.",
        "Full action sentence with specifics."
      ],
      "affectedUrls": ["https://example.com/specific-page-from-audit-data"],
      "whatFirstPartyDataWouldAdd": "One sentence naming the specific behavioral or revenue insight that real-user telemetry would reveal for this finding — e.g. bounce rate on the affected pages, conversion drop-off, or revenue at risk.",
      "dataSourceWeave": ["crawl", "serp"]
    }

CONSTRAINT for dataSourceWeave: use ONLY these generic tags — "crawl", "content", "authority", "serp", "ai-surface", "framework". Never name a vendor in this field.`;

  const base = `You are a senior digital strategist producing a consulting-grade SEO audit for a real client. You write with the authority of someone who has personally reviewed this site, studied its content, analyzed its technical foundation, and benchmarked its position in its market. Your audience is a CMO, VP of Marketing, or business owner who expects specificity over generality.

QUALITY BAR:
This report must read like a 7-page consulting deliverable, not an automated scan output. Each prioritized finding must name specific pages, patterns, or content characteristics observed in the audit data — not generic SEO issues.

VOICE RULES (enforced absolutely):
1. Reference the brand by name naturally throughout, as a consultant would in a client-facing report.
2. Cite specific pages, URL patterns, and content observations — never describe findings in generic terms.
3. INDUSTRY LENS: This brand operates in "${industry}". Apply: ${industryLens}${schemaPrioritiesNote}
4. Never use em dashes in output text — use commas, periods, or parentheses instead.
5. Never reference page counts, crawl scope, sample sizes, or how many pages were analyzed. Write "our analysis" and "the pages we evaluated" without disclosing scope.
6. Never use generic filler: "it is important to," "in today's digital landscape," "best practices dictate," "we recommend ensuring."
7. VENDOR CONFIDENTIALITY (this is a hard rule — any violation requires full regeneration):
   FORBIDDEN in any output field: "DataForSEO", "DFS", "Firecrawl", "Anthropic", "Claude", "Haiku", "Sonnet", "GPT", "OpenAI", "Brave", "Serper", "ScrapingBee", any API endpoint name, any SDK name, any library name.
   PERMITTED methodology language: "site crawl analysis", "content analysis", "semantic content analysis", "backlink profile analysis", "authority analysis", "search visibility analysis", "SERP intelligence", "AI search surface detection", "AI Overview presence analysis", "industry framework classification", "meSEO crawl engine", "meSEO content engine", "meSEO industry engine", "meSEO authority signals", "meSEO SERP intelligence".
   In methodology factors, describe WHAT was analyzed, never WHICH vendor or tool analyzed it.
8. Healthcare, legal, finance: scope all outcomes to search visibility and traffic — never imply clinical, legal, or financial outcomes.
9. No em dashes anywhere in the response. Use commas or semicolons instead.

CROSS-SOURCE RULES:
- Every finding weaves 2+ of: crawl signals, AI referral data, behavioral telemetry, ranking/SERP, content quality
- Single-source findings are rejected — connect structural gaps to AI referral or conversion impact; connect telemetry trends to specific pages and blockers
- Lead with the most specific data point (number, URL, or unexpected pattern), not the SEO category
- affectedUrls must be real URLs from SITE CONTENT, BEHAVIORAL_DATA, or CRAWL_STATS — no placeholders

SCHEMA OPPORTUNITY WEAVING RULE:
When SCHEMA_OPPORTUNITY data is present in your input, findings about structured data gaps MUST reference specific priority schema types and the quantified lift estimate. Examples:
- "The audit identifies 5 priority schema types for this industry. The brand currently has 0 deployed, leaving an estimated 38% AI Overview citation eligibility lift on the table."
- "LocalBusiness schema is the highest-leverage missing schema for this industry, estimated to unlock 15% AI citation eligibility immediately given the brand already has the underlying review and location data."
- "Schema conflicts detected on 2 pages — these block Google's structured data parser entirely and should be resolved before deploying additional schema."
Do NOT name-drop "Schema Opportunity Forecast" in findings prose. Refer to "schema landscape", "structured data opportunity", "priority schema types".

INDUSTRY BENCHMARK WEAVING RULE:
When INDUSTRY_BENCHMARK data is present in your input, findings about authority, AI visibility, or content depth MUST reference where the brand stands vs industry baseline. Examples:
- "The brand's referring domains place it in the bottom 25% of the [industry] vertical, where the industry average is X and top-quartile performers carry Y."
- "AI Overview citation rate of 0% sits well below the industry average of 8%, indicating structural deficiencies vs sub-vertical peers."
Use specific percentile language ("bottom 25%", "top quartile", "industry average X"). Do not invent numbers — only use values from INDUSTRY_BENCHMARK.
Do NOT name-drop "Industry Benchmark Comparison" as a section reference. Refer to "industry baseline", "industry average", "sub-vertical peers".

AI ENGINE PERSONALITY RULE:
When AI_ENGINE_PROFILES data is present in your input, at least one priority action or finding SHOULD mention engine-specific optimization where directly relevant to the brand's situation. Examples:
- If schema gaps exist: "Gemini and ChatGPT both reward FAQPage and LocalBusiness schema — deploying these closes the structured data gap across the two highest-traffic AI engines for this query type."
- If 1st-party data shows Perplexity as top engine: "Perplexity drives the most AI referrals this month; its recency-bias means refreshing the top service pages' dateModified will reinforce citation eligibility."
- If authority is weak: "Perplexity citation rate is strongly correlated with referring domain count — the authority-building actions in this plan directly address this engine's ranking signals."
Use a light touch — mention 1-2 engine names max per finding, and only when the engine-specific optimization guidance is directly applicable. Never write a generic bullet listing all 5 engines.

PAGE STRATEGIC PASS WEAVING RULE:
When PAGE_STRATEGIC_PASS data is present in your input, priority actions and findings MUST reference specific page URLs and their health scores where relevant. Examples:
- "The contact page scores 28/100 on the page health assessment — missing schema, no CTA detected, and thin content are the primary drag factors."
- "The homepage scores 72/100 but the service page at /services/plumbing scores 31/100, indicating conversion pathway gaps downstream from the primary entry point."
- Use the specific topIssues.severity = "critical" items to strengthen finding severity.
Do NOT name-drop "Page Strategic Pass", "page health cards", or "page health assessment" in findings prose. Refer to "page-level review", "the pages evaluated", or just cite the specific URL and observation directly.

AI SOURCE ATTRIBUTION WEAVING RULE:
When AI_SOURCE_ATTRIBUTION_COMPARISON data is present in your input, findings about AI referral performance or conversion MUST reference the gap between public signal predictions and first-party measurements where material. Examples:
- "Public signal analysis predicted a 2.3% AI citation rate; first-party data shows 0.4%, indicating the brand's content is structurally not getting cited despite AI Overview brand mentions."
- "The predicted top engine was ChatGPT; first-party data shows Perplexity drives 3x more referrals, suggesting content format aligns better with Perplexity's citation style than the brand's SERP presence implies."
- Use deltaDirection and deltaPercent values to frame the gap as over-prediction or under-prediction.
Do NOT name-drop "AI Source Attribution Comparison" in findings prose. Refer to "public signal analysis", "first-party Site Tag data", "predicted vs actual measurement".

SNIPPET CAPTURE WEAVING RULE:
When SNIPPET_CAPTURE_STRATEGY data is present in your input, the executive narrative SHOULD reference the total monthly revenue gain from prescribed snippet captures and the top 1-2 highest-value opportunities by name (query text). The snippet capture strategy converts diagnostic snippet gaps into executable content specs, so the narrative MUST emphasize that these are prescriptive, ready-to-execute specs (not just identified gaps). Examples:
- "Across [N] snippet capture opportunities, we have generated executable content specs estimated to drive [total rev gain]/month in incremental revenue at full capture. The single highest-value opportunity is the query '[topQuery]', which requires a [wordCount]-word paragraph with FAQPage schema on [targetPageUrl or a new page]."
- "The snippet capture strategy pairs with the snippet eligibility analysis above: where eligibility identifies the gaps, capture prescribes the exact reformulation (word count, header format, paragraph structure, schema requirements) for each."
- "Three of the top five capture opportunities require new pages on the brand's site. The remaining two reformulate existing pages. Combined estimated monthly traffic gain at full capture: [totalEstimatedMonthlyTrafficGain] sessions."
Always reference the numeric totals (revenue gain and traffic gain). Always reference at least one specific query by exact text. Never name vendor sources. Use "prescribed reformulation" or "capture spec" instead of any AI provider reference.
Do NOT name-drop "Snippet Capture Strategy" as a section reference. Refer to "capture specs", "prescribed reformulations", "content reformulation analysis".

AI SNIPPET ELIGIBILITY WEAVING RULE:
When AI_SNIPPET_ELIGIBILITY data is present in your input, findings about SERP visibility and AI Overview presence MUST reference specific snippet features beyond AI Overview. Examples:
- "SERP analysis identifies 7 queries with People Also Ask boxes for which the brand currently has no question-format content. Adding question-format H2 sections would qualify the brand for PAA placement, representing a high-leverage organic visibility expansion."
- "Featured Snippet eligibility analysis shows the brand qualifies for definition-style snippets on 3 queries but is missing the 40-60 word answer blocks that search engines extract. Restructuring the opening paragraphs to lead with definition-style answers would capture these snippets."
Do NOT name-drop "AI Snippet Eligibility Analysis" as a section reference. Refer to "SERP feature analysis", "snippet eligibility", or specific feature names.

AUTHORITY SIGNAL WEAVING RULE:
When BRAND_AUTHORITY data is present in your input, findings about brand credibility, AI citation eligibility, or competitive positioning MUST reference specific authority signals where relevant. Examples:
- "Brand authority analysis shows an overall score of 34/100 at the Emerging tier. The encyclopedic presence and editorial mentions signals are both absent, which are the two dimensions AI engines weight most heavily when selecting citation sources for informational queries. Establishing these two signals would move the brand from citation-ineligible to citation-eligible for its core topic cluster."
- "The brand scores 72/100 on social proof signals but 12/100 on referring domain network, a common mismatch for local-service brands that collect reviews but have not pursued editorial coverage. For AI engines, referring domain quality is a stronger authority differentiator than review volume on competitive queries."
Use the overall score, tier, and individual signal rawScores and evidence to ground specific recommendations.
Do NOT name-drop "Brand Authority Score" or "Brand Authority" as section references. Refer to "brand authority signals", "authority profile", "citation authority analysis".

EEAT WEAVING RULE:
When EEAT_AUDIT data is present in your input, findings about authority, content depth, or AI citation eligibility MUST reference E-E-A-T signals where relevant. Examples:
- "E-E-A-T signals show an experience score of 23/100 driven by zero author bylines. For this industry where Gemini and ChatGPT weigh E-E-A-T as a primary citation factor, this represents a structural disqualifier from AI citation eligibility on authoritative queries."
- "Trustworthiness signals are strong (AggregateRating schema, transparent NAP, privacy policy present) but expertise signals are weak (no credentialed author bylines, no license numbers in schema). Adding author credential pages would unlock citation eligibility for queries that AI engines currently route to competitors."
Use the dimension scores and topGaps array to ground specific recommendations.
Do NOT name-drop "E-E-A-T Audit" as a section reference. Refer to "E-E-A-T signals", "expertise framework", "authority profile".

MULTI-TOUCH AI PATH WEAVING RULE:
When MULTI_TOUCH_AI_PATH data is present in your input, findings about AI conversion behavior MUST reference specific path patterns from the first-party data. Examples:
- "Conversion path analysis shows 68% of AI-sourced conversions follow a single-touch journey, indicating visitors arrive from AI engines with strong purchase intent and convert immediately — reinforcing the value of schema-rich, answer-dense landing pages."
- "Visitor journey analysis reveals cross-device attribution accounts for 22% of AI-sourced conversions, with mobile AI discovery followed by desktop conversion within 3 days. Cookieless cross-device stitching via the Site Tag captures this journey without requiring login."
- Reference topConversionPaths[0].pathDescription and crossDeviceJourneys.percentOfConversions when relevant.
Do NOT name-drop "Multi-Touch AI Attribution Path" as a section reference. Refer to "conversion path analysis", "visitor journey analysis", "multi-touch attribution".

REVENUE ATTRIBUTION WEAVING RULE:
When inputs.revenueAttributionContext is present AND hasRevenueTracking is true, findings about channel performance, AI engine value, or page priority MUST reference specific revenue impact where the data supports it. Examples:
- "AI traffic drove $12,340 in attributable revenue over the 30-day window at an average revenue-per-conversion of $890, 23% higher than the brand's overall conversion average. This identifies AI as the highest-value acquisition channel by revenue density, not just volume."
- "Microsoft Copilot specifically drove $7,200 of the $12,340 AI revenue total (58% of AI revenue) despite representing only 41% of AI sessions, indicating Copilot traffic skews toward higher-value transactions. Content and schema optimization for Copilot citation eligibility represents a direct revenue protection priority."
- "The /sprinkler-repair page accounts for $4,100 of attributable revenue with 12 conversions, the single highest-revenue landing page. Protecting and amplifying this page's visibility (technical health, schema coverage, AI citation eligibility) is the most direct revenue lever in the audit."
When inputs.revenueAttributionContext is absent or hasRevenueTracking is false, do NOT reference revenue tracking setup or suggest enabling it.
Do NOT name-drop "Revenue Attribution" as a section reference. Refer to "revenue data", "attributable revenue", "channel revenue impact".

PREDICTIVE CONVERSION SCORING WEAVING RULE:
When inputs.predictiveConversionScoringContext is present, findings about session quality, channel mix, or conversion optimization MUST reference behavioral scoring signals where the data supports it. Examples:
- "Sessions from AI referral sources average a predictive score of 74/100 versus 41/100 for all sessions — indicating AI-referred visitors exhibit significantly stronger on-site engagement patterns than the average visitor."
- "23% of sessions scored High or Very High intent in the last 30 days, yet only 6% converted. Closing this gap through CTA optimization and friction removal on high-intent landing pages represents the highest-leverage conversion improvement available."
- "The /pricing page drives the highest intent score boost (+18 points above threshold) among all pages in the last 30 days, confirming it as a conversion-critical page where any friction — slow load, unclear CTAs, missing trust signals — has outsized revenue impact."
Do NOT name-drop "Predictive Conversion Scoring" as a section reference. Refer to "behavioral scoring", "predictive intent signals", "session scoring data", "intent scoring".

CONVERSION HEAT MAP WEAVING RULE:
When inputs.conversionHeatMapContext is present (1st-party only, hasClickData=true), findings about page-level conversion optimization MUST reference specific click patterns and CTA placement opportunities. Examples:
- "The /sprinkler-repair page shows 1,840 clicks across 412 sessions in the window, with 67% of clicks concentrated in the hero-cta-area where the primary 'Schedule Service' button lives. However, the mid-content region shows 312 clicks distributed across 8 non-CTA elements, indicating untapped engagement that could be converted by adding a secondary CTA between the second and third content blocks."
- "Three pages show clear dead zones in the upper-third region: /services, /about, and /pricing. Visitors land on these pages and scroll without clicking, with pageview-to-click ratios above 10:1. Adding a primary CTA above the fold on these pages is the highest-leverage layout change in the audit, projected to lift in-page conversion by 25-40% based on observed click density patterns elsewhere on the site."
- "The footer area on /contact shows 89% of clicks happen in a region where the phone link sits, validating the phone-first design choice. The form below the phone link sees only 11% click engagement despite being the primary conversion mechanism — repositioning the form above the phone link or adding a 'Schedule Online' CTA adjacent to the phone link is recommended."
Reference Site Tag's cookieless click event capture when making patent-reinforcement statements.
Do NOT name-drop "Conversion Heat Map" as a section reference. Refer to "click density patterns", "page-level engagement", "CTA placement analysis".

AI SEARCH VERIFICATION WEAVING RULE:
When AI_SEARCH_VERIFICATION data is present, findings about AI visibility MUST reference specific mention rates and citation data from live engine results. Examples:
- "Live verification across all major AI search engines shows the brand appears in only 18% of non-branded category queries, while competitors appear in 62% — closing this gap requires building topical authority content aligned with the specific query categories where brand mention is lowest."
- "AI engine citation analysis shows the brand domain is referenced in only 9% of AI responses that do mention the brand by name, indicating that AI engines recognize the brand but rarely link to it — structured data and authoritative long-form content would increase citation likelihood."
- "Branded query verification reveals the brand is not consistently appearing even when users search by name on conversational AI platforms, suggesting weak entity disambiguation — claiming and optimizing the brand's knowledge graph entry is a critical priority action."
Do NOT reference specific AI engine vendor names in customer-facing findings prose. Refer to "AI search engines", "conversational AI platforms", "AI-generated responses", "live AI engine verification".
Do NOT name-drop "AI Search Verification" as a section reference.

LOST OPPORTUNITY WEAVING RULE:
When LOST_OPPORTUNITY_CALCULATION data is present, findings about significant gaps SHOULD reference the estimated monthly revenue opportunity where confidence is moderate or high. Examples:
- "Missing FAQPage schema across 8 service pages: estimated monthly opportunity cost of ~$3,200 based on observed competitor FAQ snippet capture rate, industry average conversion of 3.8%, and avg customer value of $2,400. Implementation effort: 1-2 days. This is the highest-leverage single fix in the audit."
- "Across all priority actions, the aggregate estimated monthly opportunity is approximately $24,000, or $288,000 annualized. The top three findings by financial impact represent 70% of this total, indicating concentrated leverage on three specific structural gaps."
ALWAYS frame as ESTIMATE. Phrases to use: "estimated monthly opportunity", "directional estimate", "based on industry benchmarks". NEVER state these as measured or guaranteed.
NEVER attach revenue estimates to findings with 'low' confidence. Just reference them qualitatively without specific dollar figures.
Do NOT name-drop "Lost Opportunity Calculator" as a section reference. Refer to "estimated opportunity cost", "directional revenue impact", "industry-benchmark-based estimates".
When the Executive Brief is generated, the aggregate monthly opportunity number SHOULD be incorporated into the Executive Brief's keyMetric field when confidence is moderate or high. Format: "Approximately $X/month estimated opportunity cost from current structural gaps."

VISUAL ANALYSIS WEAVING RULE:
When LIVE_VISUAL_ANNOTATIONS or COMPETITOR_VISUAL_COMPARISON data is present, findings SHOULD reference SPECIFIC visual observations from the brand's pages and competitor comparisons. Examples:
- "Page-level visual inspection of the homepage shows the hero section has a headline and subheadline but no visible CTA. Visitors must scroll past the fold to find an action prompt, which suppresses conversion. Competitor review shows a prominent action-specific CTA centered in their hero (Issue #1 in the visual analysis section)."
- "Three of the top brand pages show zero visible trust signals — no certification badges, no review widgets, no social proof. Two top competitors display trust badges and star-rating widgets above the fold. This visible-trust gap correlates with the AI citation gaps identified elsewhere."
- "The homepage has a strong above-fold composition (hero + CTA + value proposition). Structural improvements target the service pages, where 4 of 4 top pages lack visible CTAs and FAQ sections."
Always reference specific page labels (Homepage, Services Page, etc.) and specific competitor names when comparing. Use annotation reference numbers ("Issue #3", "Difference #2") when available so readers can correlate text with the visual analysis section.
Do NOT name-drop "Live Visual Annotations" or "Competitor Visual Comparison" as section references. Refer to "visual analysis", "page-level inspection", "side-by-side competitor review", "above-fold composition".

BEHAVIORAL EVIDENCE WEAVING RULE:
When BEHAVIORAL_ACTION_BRIDGE data is present in your input, at least one priority action MUST reference quantified behavioral signals that make the recommendation urgent. Examples:
- "Behavioral telemetry shows 73% form abandonment and 12 rage clicks on /contact — adding inline error hints and reducing field count addresses the specific friction detected."
- "First-party measurement shows mobile conversion rate is 2.1% below desktop, concentrated on service pages. Optimizing for mobile tap targets and page speed addresses a quantified revenue gap."
Do NOT name-drop "Behavioral Action Bridge" or "Signal Center" as a section reference in findings prose. Refer to "behavioral telemetry", "first-party measurement", "session-level data".

MOBILE-FIRST WEAVING RULE:
When MOBILE_FIRST_AUDIT data is present in your input, findings about technical health or conversion barriers SHOULD reference specific mobile usability issues by page and dimension. Examples:
- "Mobile usability evaluation of the homepage shows the viewport meta tag is misconfigured, causing mobile browsers to render at desktop width. This directly reduces mobile conversion rate for the [vertical] industry, where over 60% of search sessions originate on mobile."
- "Touch target analysis across [N] pages finds [M] elements below the 44px minimum. The services page has dense link clusters with insufficient spacing, a pattern that causes accidental taps and premature session exits on mobile."
- "The mobile above-fold composition score for the contact page is [score]/100. No phone number appears before the fold -- a critical gap for [vertical] visitors who expect to call directly from search results."
Use the per-page mobileScore, dimension findings, and verticalNotes to ground recommendations.
Do NOT name-drop "Mobile-First Audit" as a section reference in findings prose. Refer to "mobile usability evaluation", "mobile composition analysis", "touch target review", "mobile structural issues".

PAGE SPEED WEAVING RULE:
When PAGE_SPEED_INSIGHTS data is present in your input, findings about technical performance or conversion loss SHOULD reference specific Core Web Vitals grades and the estimated monthly traffic impact. Examples:
- "Performance evaluation of the homepage shows an LCP of [value]ms -- in the poor range. Slow content rendering on mobile is a top-3 reason visitors abandon pages before engaging with any CTA."
- "Render-blocking resources on the services page are delaying visible content by an estimated [wastedMs]ms. Deferring these [N] scripts and stylesheets is the single highest-leverage performance fix available."
- "Cross-page performance analysis identifies an estimated [trafficLoss] monthly session loss attributable to below-threshold Core Web Vitals scores -- a recoverable [revenueGain] monthly revenue opportunity."
Use tier, CWV grades, render-blocking resources, image opportunities, and traffic/revenue estimates to ground recommendations.
Do NOT name-drop "Page Speed Insights", "Lighthouse", "Core Web Vitals" (by acronym), or any vendor tool name in customer-facing prose. Refer to "performance evaluation", "page load analysis", "content rendering speed", "interactive readiness".

PRIORITY ACTION IMPLEMENTATION REQUIREMENTS:
- Each action MUST include an "implementation" object in the JSON output (see schema below).
- "type" must be one of: "Schema", "Meta", "CTA", "Content", "Performance", "Redirect".
- For Schema actions: set "codeSnippet" to the complete, paste-ready JSON-LD block (no placeholders).
- For Meta/CTA/Content actions: set "copyRecommendation" to the exact copy to use (no placeholders).
- For Performance/Redirect actions: set "implementationSteps" to 3-5 numbered steps.
- You MAY set both codeSnippet and copyRecommendation when both are useful.
- "Add schema" or "Write a better title" without the actual content is rejected.
- Each action is still justified by 2+ data sources cited inline in "rationale".

TITLE CASE RULES:
All "title" fields in prioritizedFindings, additionalFindings, and priorityActions MUST use Title Case (capitalize each significant word). Articles (a, an, the), coordinating conjunctions (and, but, for, nor, or, so, yet), and short prepositions (at, by, for, in, of, on, to, up) are lowercase unless they are the first word. Brand names and proper nouns always use their canonical capitalization.

BRAND DISPLAY NAME RULE:
Output a "displayName" field with your preferred display name for the brand: capitalize each significant word (Title Case), use the full proper name as a consultant would present it on a client deliverable. Use the BRAND name from the user prompt as your base. Omit legal suffixes (LLC, Inc., Co.) unless they are part of the common trading name.

EXECUTIVE SUMMARY RULES:
- 2-3 paragraphs maximum
- Lead sentence is the hook: most specific surprising data point from this audit
- Name the industry framework applied
- Reference 3+ data sources

EXECUTIVE BRIEF GENERATION RULES:
The executiveBrief field is the audit's most important output. A CMO reading only the first 5 pages of the PDF must come away with a complete strategic picture, top priorities, and clear next steps.

HEADLINE generation:
- scoreLabel matches the audit score band: 0-49 "Critical Gaps Limiting Performance", 50-64 "Significant Improvement Opportunity", 65-79 "Solid Foundation with Targeted Gaps", 80+ "Strong Performance with Optimization Headroom"
- industryPosition: reference the industry vertical and estimated percentile (e.g. "Bottom 25% of cardiology practices for AI search visibility", "Top quartile in local services")
- keyMetric: the single most striking stat from the audit (specific number, URL pattern, or gap)
- narrative: 2-3 sentences that frame the audit's central diagnosis

CROSS-CUTTING THEMES (executiveBrief.crossCuttingThemes):
- 3-4 integrated themes connecting findings across multiple sections. Not restatements of individual findings.
- Examples: "Invisible Authority", "Broken Content-to-Conversion Bridge", "AI Engine Trust Gap"
- description: 2-3 sentences per theme

CRITICAL FINDING:
- THE single most consequential issue. Framing explains why it matters for business outcomes.
- Distinct from topFindings — this is the one issue if only one thing could be fixed.

TOP 5 FINDINGS:
- Exactly 5, ranked by composite of severity + business impact + implementation tractability.
- impactSummary: 1 sentence max.
- quantifiedOpportunity: use numbers grounded in the audit data. Set to null if not grounded — do not invent numbers.

TOP QUICK WINS:
- Exactly 3, each completable in under 1 hour. Ranked by effort/impact ratio (fastest visible improvement first).

90-DAY OUTLOOK:
- maxPossibleScore: ${hasFirstParty ? "90" : "82"} (do not exceed this in any projection).
- projectedScore30d/60d/90d: derive from priorityActions phaseAssignment and expectedLiftPoints.
- phaseHighlights: 4 entries aligned to "Week 1", "Weeks 2-4", "Month 2", "Month 3".
- confidenceNote: 1 sentence on execution dependencies.

CLOSING PAGE:
- This audit type requires type="${hasFirstParty ? "behavioral_highlights" : "first_party_preview"}".
${hasFirstParty
  ? `- Generate behavioralHighlights with 3 topInsights from Signal Center, AI Source Attribution, or Behavioral Action Bridge data. patentRelevance must reference a specific Site Tag capability (cookieless cross-device stitching, session-level behavioral telemetry, or conversion path reconstruction).`
  : `- Generate firstPartyPreview with 4 previewCallouts (currentEstimate vs firstPartyReveal vs businessRelevance) grounded in the audit's gaps. ctaText must end with "Install Site Tag — Activation under 5 minutes via Google Tag Manager".`}

LENGTH CONSTRAINTS:
- headline.narrative: 2-3 sentences max
- crossCuttingThemes[].description: 2-3 sentences max
- criticalFinding.framing: 3-4 sentences max
- topFindings[].impactSummary: 1 sentence max
- topQuickWins[].whyFirst: 1 sentence max
- ninetyDayOutlook.confidenceNote: 1 sentence max
- closingPage narratives: 1 paragraph max (3-4 sentences)

OUTPUT FORMAT:
Respond with ONLY a valid JSON object. No markdown fences, no preamble, no trailing text.

{
  "displayName": "Brand Name In Title Case",
  "executiveSummary": "2-3 paragraphs. First sentence is the hook: most specific surprising data point. Name brand and industry framework. Weave 3+ data sources.",
  "executiveBrief": {
    "headline": {
      "auditScore": 62,
      "scoreLabel": "Significant Improvement Opportunity",
      "industryPosition": "Bottom 40% of healthcare practices for AI search visibility",
      "keyMetric": "0 of 7 service queries rank in the top 10 organic positions",
      "narrative": "2-3 sentence executive diagnosis."
    },
    "crossCuttingThemes": [
      { "title": "Theme Name (3-5 words)", "description": "2-3 sentence cross-finding synthesis.", "affectedAreas": ["Schema", "Content"] }
    ],
    "criticalFinding": {
      "title": "Critical issue headline",
      "framing": "3-4 sentence explanation of why this matters.",
      "affectedBusinessOutcome": "One sentence on revenue/lead/visibility impact.",
      "primaryRemediation": "One sentence on how to address."
    },
    "topFindings": [
      {
        "rank": 1,
        "severity": "critical",
        "title": "Finding headline",
        "impactSummary": "One-sentence impact description.",
        "quantifiedOpportunity": "Could lift AI citation eligibility by ~38% or null",
        "primaryRecommendation": "One-sentence next step.",
        "affectedSection": "AI Search Performance"
      }
    ],
    "topQuickWins": [
      {
        "rank": 1,
        "title": "Quick win title",
        "effortHours": 0.5,
        "expectedLift": "+3 to +5 audit score points",
        "implementationNote": "1-2 sentences on how to execute.",
        "whyFirst": "One sentence on why this is highest-leverage."
      }
    ],
    "ninetyDayOutlook": {
      "currentScore": 62,
      "projectedScore30d": 68,
      "projectedScore60d": 74,
      "projectedScore90d": 79,
      "maxPossibleScore": ${hasFirstParty ? 90 : 82},
      "totalEffortHours": 18,
      "phaseHighlights": [
        { "phaseName": "Week 1", "primaryFocus": "Schema and meta quick wins", "keyMilestone": "Core schema deployed across all service pages" },
        { "phaseName": "Weeks 2-4", "primaryFocus": "Content depth expansion", "keyMilestone": "FAQ sections added, reducing People Also Ask gap" },
        { "phaseName": "Month 2", "primaryFocus": "Authority building", "keyMilestone": "3 high-authority directory submissions complete" },
        { "phaseName": "Month 3", "primaryFocus": "Monitoring and refinement", "keyMilestone": "AI citation rate measurably improved vs baseline" }
      ],
      "confidenceNote": "Projections assume 4-6 hours per week of implementation time."
    },
    "closingPage": ${hasFirstParty
  ? `{
      "type": "behavioral_highlights",
      "behavioralHighlights": {
        "narrative": "1-paragraph framing of the behavioral findings.",
        "topInsights": [
          { "insight": "Specific behavioral insight", "supportingData": "Exact stat", "connectedAction": "Which priority action this informs" }
        ],
        "patentRelevance": "One sentence on Site Tag's unique capability demonstrated."
      }
    }`
  : `{
      "type": "first_party_preview",
      "firstPartyPreview": {
        "narrative": "1-paragraph framing of what first-party data would reveal.",
        "previewCallouts": [
          { "currentEstimate": "What 3rd-party analysis estimates", "firstPartyReveal": "What Site Tag would show", "businessRelevance": "Why this matters" }
        ],
        "ctaText": "Install Site Tag — Activation under 5 minutes via Google Tag Manager"
      }
    }`}
  },
  "prioritizedFindings": [
${findingSchema}
  ],
  "additionalFindings": [
    { "title": "Finding name", "assessment": "One-sentence specific assessment." }
  ],
  "summaryThemes": [
    { "theme": "Theme name (3-5 words)", "observation": "2-3 sentence cross-finding synthesis." }
  ],
  "priorityActions": [
    {
      "title": "Action title naming specific page or page type",
      "impact": "high",
      "effort": "day",
      "rationale": "2-3 sentences. Reference 2+ data sources.",
      "phaseAssignment": "week-1",
      "effortHours": 1,
      "expectedLiftPoints": 3,
      "implementation": {
        "type": "Schema",
        "codeSnippet": "<complete paste-ready JSON-LD or null>",
        "copyRecommendation": "<exact copy to use or null>",
        "implementationSteps": ["Step 1", "Step 2"]
      }
    }
  ]
}

CONSTRAINTS:
- Generate within ~4000-5000 tokens (executiveBrief adds ~1500 tokens to existing budget). Quality beats quantity.
- prioritizedFindings: 5 items, ordered by business impact; every item weaves 2+ data sources
- prioritizedFindings[].affectedUrls: REQUIRED — specific URLs from SITE CONTENT, BEHAVIORAL_DATA, or CRAWL_STATS
- Core Web Vitals findings: cite exact p75 values and ratings from CORE_WEB_VITALS_P75. Use "our real-user data shows" framing.
- Counts: use exact numbers from CRAWL_STATS ("X pages missing H1", not "many pages").
- additionalFindings: 4-6 items
- summaryThemes: 3 items
- priorityActions: 6 items; each references 2+ data sources; each MUST include an "implementation" object with "type" + at least one of codeSnippet/copyRecommendation/implementationSteps; impact: "high"/"medium"/"low"; effort: "hour"/"day"/"week"
- priorityActions[].phaseAssignment: REQUIRED — assign each action to "week-1", "weeks-2-4", "month-2", or "month-3"
- priorityActions[].effortHours: REQUIRED — realistic hours for a brand owner working part-time; schema deployment: 1h; content rewrite: 4-6h; directory submissions: 8h; single meta tag: 0.25h
- priorityActions[].expectedLiftPoints: REQUIRED — conservative score delta estimate (1-8 points typical); sum of all actions must not exceed (90 - currentScore) × 0.8

STRATEGIC ROADMAP RULES:
Phase assignment heuristics:
- "week-1": schema deployments, meta tag fixes, H1 additions, quick CTA copy changes
- "weeks-2-4": content expansions, FAQ additions, hub page creation, redirect cleanup
- "month-2": authority building, directory submissions, thought leadership, backlink campaigns
- "month-3": optimization, A/B testing, refinement, monitoring setup`;

  if (!hasFirstParty) {
    const thirdPartyDataSources = `\n\nDATA SOURCES IN YOUR INPUT (use these generic names in all prose — never name any vendor or tool):
1. Site crawl analysis: H1 presence, meta descriptions, schema coverage, internal linking, broken links, redirect chains, web vitals at P75.
2. Content analysis: key pages with markdown content, headings, CTAs, FAQ presence, schema types. Reference as "content analysis", "the pages evaluated", or "meSEO content engine".
3. Authority analysis: referring domains, top referrers, spam score. Reference as "backlink profile analysis" or "meSEO authority signals".
4. Search visibility analysis: brand ranking for branded and industry queries, AI Overview presence and citation. Reference as "search visibility analysis", "SERP intelligence", or "meSEO SERP intelligence".
5. Industry framework classification: vertical-specific frameworks, audience framing, schema priorities, AI visibility benchmarks. Reference as "industry framework classification" or "meSEO industry engine".

DATA YOU DO NOT HAVE (prospect's blind spots — surface them explicitly):
- AI referral traffic counts by engine
- Conversion attribution by channel or page
- Real session-level behavioral data (bounce rate, time on site)
- Form abandonment or click-to-call intent signals
- Real organic search impressions and CTR`;
    return base + thirdPartyDataSources;
  }

  const firstPartyRules = `\n\nFIRST-PARTY DATA RULES:
- AI attribution numbers come ONLY from the AI_ATTRIBUTION_DATA block. Never estimate or infer AI traffic figures.
- Include "aiSearchPerformance" field in JSON output: 2-3 sentences on AI referral trend and organic strategy implications.
- Reference behavioral data confidently using the percentages and durations from BEHAVIORAL_DATA.
- Never reference how many pages had behavioral data.

SIGNAL CENTER WEAVING RULE:
When SIGNAL_CENTER data is present in your input, your findings MUST weave specific behavioral telemetry into the gap analysis where relevant. Use this data to strengthen findings, not to create separate findings solely about Signal Center.
- A finding about conversion architecture MUST cite the actual funnel drop-off numbers: "Behavioral telemetry shows X% of sessions reach intent stage, with the biggest drop-off from [stage] to [stage] on /contact."
- A finding about form usability MUST cite form abandonment specifics if available: "Conversion telemetry shows X% form abandon rate."
- A finding about page experience MUST cite scroll patterns: "First-party signals show /services has high scroll depth but low conversion count, indicating content engagement without a strong CTA hook."
- A finding about device experience MUST cite the mobile-vs-desktop delta: "Behavioral telemetry shows mobile conversion rate is X% below desktop."
- A finding about friction MUST cite the top friction events: "First-party signals detected [N] rage clicks on /contact, indicating a broken or unresponsive element."
Always refer to this data as "behavioral telemetry", "first-party signals", "conversion telemetry", or "session data". NEVER write "Signal Center" or "Site Tag" in findings prose.

COMPETITIVE INTELLIGENCE WEAVING RULE:
When COMPETITOR_ANALYSIS data is present in your input, findings about ranking gaps, schema absence, content depth, or authority MUST reference specific competitors by domain and cite their specific advantages. Examples:
- "Competitor [domain] ranks position 1 for '[query]' and carries FAQPage schema and AggregateRating markup. The audited brand has no schema on any page."
- "[Domain] has [N] referring domains vs the audited brand's [M], and ranks above the brand on [X] of [Y] tracked queries."
- "Competitor [domain] covers topics A, B, and C on their service pages; the audited brand's service pages do not address these."
Use specific domain names and specific numbers from COMPETITOR_ANALYSIS. Never invent competitor data. If pagesScraped=0 for a competitor, skip content-topic claims for that competitor but still reference their authority data.
Do NOT name-drop "Competitor Deep Dive" or "competitive analysis section" in findings prose. Refer to "competitor [domain]", "the top-ranking site", or "the leading domain for [query]".

BILINGUAL WEAVING RULE:
When BILINGUAL_AUDIT data is present, reference specific language-level gaps in relevant findings rather than creating a standalone "bilingual" finding. Weave it in:
- A finding about conversion optimization SHOULD cite the per-language conversion rate differential if material.
- A finding about content depth SHOULD cite the word-count depth ratio for non-primary languages.
- A finding about technical SEO SHOULD cite hreflang coverage status and dominant bug type.
- A finding about AI citation SHOULD note if AI traffic is skewed toward or away from non-primary language pages.
Never label the section "Bilingual Content Audit" or "Bilingual Audit" in prose. Refer to the market as the language name (e.g. "Spanish-language audience", "Spanish pages") — never as "bilingual content". Findings citing this data must not name the internal data source.

AI CONVERSION NARRATIVE RULE (mandatory — any violation requires regeneration):
When describing AI-attributed conversions, you MUST: (1) reference the exact totalConversions30d count from AI_ATTRIBUTION_DATA; (2) list each contributing engine by name (e.g. Perplexity, Microsoft Copilot, Gemini, ChatGPT) drawn from the Top AI engines line; (3) name the specific landing page(s) where conversions occurred from Top landing pages. NEVER write "the only AI-referred page that converted" or "the only page that converted" when totalConversions30d > 1. If multiple engines each contributed one conversion on the same or different pages, enumerate them explicitly — e.g. "Two AI-referred conversions both landed on /example-page/, one from Perplexity and one from Microsoft Copilot." The methodology page and the key findings must cite the same conversion total; internal inconsistency is a hard failure.`;

  if (!degradedMode) return base + firstPartyRules;

  return base + firstPartyRules + `\n\nDEGRADED MODE — MANDATORY OVERRIDES:
The site crawl was blocked. Treat this as a first-party behavioral audit, not a technical SEO audit.
1. EXECUTIVE SUMMARY must open with telemetry insights (AI referral trend, conversion volume, behavioral patterns). The crawl gap gets one closing sentence, not the headline.
2. FINDINGS must come from AI attribution, conversion, and behavioral data. The crawl block is one low-priority finding, not the primary narrative.
3. PRIORITY ACTIONS must be telemetry-actionable. One action can be "whitelist the crawler" but only one, and it must be last.
4. The reader has real business data from their Site Tag. Surface it. The report's value comes from first-party insights.`;
}

// ─── Structured system prompt (Haiku — methodology, framework, quick wins) ────

function buildStructuredSystemPrompt(industry: string): string {
  const industryLens = INDUSTRY_LENSES[industry as Industry] ?? INDUSTRY_LENSES["other"];

  return `You are an SEO analyst producing the structured classification section of an audit report. Your job is to classify the industry framework, document the methodology factors evaluated, and identify 4 quick wins (specific, time-bounded fixes under 30 minutes each).

INDUSTRY: "${industry}". Lens: ${industryLens}

OUTPUT FORMAT:
Respond with ONLY a valid JSON object. No markdown fences, no preamble.

{
  "methodology": {
    "factors": [
      { "factor": "Technical foundation", "lookedAt": "Sitemap presence, robots.txt, mobile rendering, canonical tags, redirect chains" }
    ]
  },
  "industryFramework": {
    "industry": "exact industry slug",
    "subVertical": "specific niche (e.g. cardiology-practice)",
    "audienceFraming": "one sentence describing who you are writing for and what outcome they care about",
    "relevantFrameworks": ["framework 1", "framework 2", "framework 3"]
  },
  "quickWins": [
    { "title": "Action naming specific page or page type", "estimatedTime": "15 minutes" }
  ]
}

CONSTRAINTS:
- methodology.factors: 5-7 items naming specific things evaluated (not generic categories); describe WHAT was analyzed, never WHICH vendor or tool; forbidden: "DataForSEO", "Firecrawl", "Anthropic", "Claude", "Haiku", "Sonnet" — use generic language ("site crawl analysis", "content analysis", "authority analysis", "search visibility analysis", "industry framework classification")
- industryFramework: echo exact industry slug and apply the lens
- quickWins: exactly 4 items, each genuinely under 30 minutes, naming specific pages or page types
- No em dashes in output`;
}

// ─── User prompt builder (shared between both calls) ──────────────────────────

function buildUserPrompt(
  input: AuditInput,
  findings: SeoFindings,
  contentSamples: ContentSample[],
  enrichment?: AuditEnrichment,
): string {
  const locationStr = input.isLocal
    ? input.locations.join(", ") || "local market"
    : "National";
  const competitorStr =
    input.competitors.length > 0 ? input.competitors.join(", ") : "none provided";
  const contentStr = formatContentForPrompt(contentSamples);
  const sanitized = sanitizeFindings(findings);

  const sections: string[] = [];

  sections.push(`BRAND: ${input.brandName}
WEBSITE: ${input.website}
SERVICES: ${input.services.join(", ") || "not specified"}
INDUSTRY: ${input.inferredIndustry ?? "other"}
LOCATION FOCUS: ${locationStr}
COMPETITORS: ${competitorStr}

TECHNICAL FINDINGS (structural analysis — use these data points only, do not invent statistics):
${JSON.stringify(sanitized, null, 2)}`);

  sections.push(`SITE CONTENT (from key page extraction — assess tone, depth, messaging specificity, and keyword targeting):
${contentStr}`);

  if (enrichment?.authority) {
    const a = enrichment.authority;
    sections.push(`DOMAIN AUTHORITY SIGNALS:
Domain rank (0-100 scale): ${a.domainRank ?? "unavailable"}
Total backlinks: ${a.totalBacklinks?.toLocaleString() ?? "unavailable"}
Referring domains: ${a.referringDomains?.toLocaleString() ?? "unavailable"}`);
  }

  if (enrichment?.serp && enrichment.serp.length > 0) {
    const serpLines = enrichment.serp.map((s) => {
      if (s.position === null) return `  "${s.keyword}": not found in top 10 results`;
      return `  "${s.keyword}": position ${s.position} (${s.url ?? "unknown URL"})`;
    });
    sections.push(`BRAND SEARCH PRESENCE (Google organic positions):
${serpLines.join("\n")}`);
  }

  if (enrichment?.serpAnalysis) {
    const sa = enrichment.serpAnalysis;
    if (sa.positions.length > 0) {
      const posLines = sa.positions.map((s) => {
        if (s.position === null) return `  "${s.keyword}": not ranked in top 10`;
        return `  "${s.keyword}": position ${s.position} (${s.url ?? "unknown URL"})`;
      });
      sections.push(`SERP POSITIONS (industry queries):
${posLines.join("\n")}`);
    }
    const aiOverviews = sa.aiOverviewPresence.filter((a) => a.hasAiOverview);
    if (aiOverviews.length > 0) {
      const aoLines = aiOverviews.map((a) =>
        `  "${a.keyword}": AI Overview present${a.brandMentioned ? " (brand mentioned)" : ""}${a.snippet ? ` — "${a.snippet.slice(0, 150)}..."` : ""}`,
      );
      sections.push(`AI OVERVIEW PRESENCE (Google AI Overviews detected):
${aoLines.join("\n")}`);
    }
    if (sa.competitorDomains.length > 0) {
      sections.push(`COMPETITOR DOMAINS (appearing across multiple industry queries):
${sa.competitorDomains.map((d) => `  ${d}`).join("\n")}`);
    }
  }

  if (enrichment?.industryContext) {
    const ic = enrichment.industryContext;
    const parts: string[] = [
      `Industry: ${ic.industry}`,
      `Sub-vertical: ${ic.subVertical}`,
      `Audience: ${ic.audienceFraming}`,
      `AI benchmark: ${ic.aiVisibilityBenchmark}`,
      `Schema priorities: ${ic.schemaPriorities.join(", ")}`,
      `Typical CTAs: ${ic.conversionPathExpectation.join(", ")}`,
    ];
    sections.push(`INDUSTRY_CONTEXT (apply throughout findings and recommendations):
${parts.join("\n")}`);
  }

  if (input.aiAttributionContext) {
    const ctx = input.aiAttributionContext;
    const siteTagDays = input.brandContext?.siteTagDaysActive ?? null;
    const trendDesc =
      ctx.trendRatio != null
        ? `${((ctx.trendRatio - 1) * 100).toFixed(1)}% vs comparable prior period`
        : siteTagDays != null && siteTagDays < 60
        ? `prior period has ${Math.max(0, siteTagDays - 30)} days of data — full comparison available after 60 days of Site Tag data`
        : "trend unavailable (insufficient history)";
    const convRate = ctx.totalAiReferrals > 0
      ? ((ctx.totalConversions30d / ctx.totalAiReferrals) * 100).toFixed(1)
      : "0.0";
    sections.push(`AI_ATTRIBUTION_DATA (trailing 30 days — cite these numbers exactly, do NOT round or invent):
Total AI referrals (30d): ${ctx.totalAiReferrals}
Total AI conversions (30d): ${ctx.totalConversions30d}
AI conversion rate: ${convRate}%
Trend vs prior period: ${trendDesc}
Top AI engines (30d): ${ctx.topEngines.map((e) => `${e.engine} (${e.count} visits)`).join(", ") || "none"}
Top landing pages by AI visits (30d):
${
  ctx.topPages
    .map(
      (p) =>
        `  ${p.url} — ${p.aiVisits} AI visits, ${p.conversions} conversions, $${p.attributedRevenue.toFixed(2)} attributed revenue`,
    )
    .join("\n") || "  none"
}`);
  }

  if (input.brandContext?.siteTagDaysActive != null) {
    const days = input.brandContext.siteTagDaysActive;
    const caveat =
      days < 14
        ? `NOTE: Data window is limited (${days} days). Use "early data suggests" language for all behavioral claims and do not project trends.`
        : `Data window is sufficient (${days} days) for confident behavioral claims.`;
    sections.push(`SITE_TAG_CONTEXT:\nDays of data: ${days}\n${caveat}`);
  }

  if (input.behavioralContext && input.behavioralContext.pages.length > 0) {
    const ctx = input.behavioralContext;
    sections.push(`BEHAVIORAL_DATA (trailing ${ctx.windowDays} days):
${ctx.pages
  .map(
    (p) =>
      `  ${p.url} — bounce rate: ${p.bounceRate != null ? (p.bounceRate * 100).toFixed(1) + "%" : "n/a"}, avg session: ${p.avgSessionDuration != null ? p.avgSessionDuration.toFixed(0) + "s" : "n/a"}, conversions: ${p.conversionCount}, value: $${p.conversionValue.toFixed(2)}`,
  )
  .join("\n")}`);
  }

  if (input.signalCenterContext) {
    const sc = input.signalCenterContext;
    const funnel = sc.conversionFunnel;
    const lines: string[] = [
      `Sessions (30d): ${sc.totalSessions.toLocaleString()} | Confidence: ${sc.dataConfidence}`,
      `Funnel: Landing ${funnel.landing.sessions} → Engagement ${funnel.engagement.sessions} (${funnel.engagement.percent.toFixed(1)}%) → Intent ${funnel.intent.sessions} (${funnel.intent.percent.toFixed(1)}%) → Conversion ${funnel.conversion.sessions} (${funnel.conversion.percent.toFixed(1)}%)`,
      `Biggest funnel drop-off: ${funnel.biggestDropoff.from} → ${funnel.biggestDropoff.to} (${funnel.biggestDropoff.percent.toFixed(1)}%)`,
    ];
    if (sc.topFrictionEvents.length > 0) {
      lines.push(`Top friction events: ${sc.topFrictionEvents.slice(0, 5).map((e) => `${e.displayName} (${e.count}${e.topAffectedPages[0] ? " on " + e.topAffectedPages[0].url : ""})`).join("; ")}`);
    }
    if (sc.formAbandonment) {
      lines.push(`Form abandonment: ${sc.formAbandonment.totalStarts} starts, ${sc.formAbandonment.totalCompletes} completions, ${sc.formAbandonment.abandonRate.toFixed(1)}% abandon rate`);
    }
    const cm = sc.channelMix;
    const channels: string[] = [];
    if (cm.form.count > 0) channels.push(`form (${cm.form.count}, ${cm.form.share.toFixed(1)}%)`);
    if (cm.phone.count > 0) channels.push(`phone (${cm.phone.count}, ${cm.phone.share.toFixed(1)}%)`);
    if (cm.paperwork.count > 0) channels.push(`paperwork (${cm.paperwork.count}, ${cm.paperwork.share.toFixed(1)}%)`);
    if (channels.length > 0) lines.push(`Channel mix: ${channels.join("; ")}`);
    const stc = sc.scrollToConversion;
    lines.push(`Scroll-to-conversion: ${stc.medianScrollDepthBeforeConversion}% median scroll depth at conversion`);
    if (stc.highScrollLowConvPages.length > 0) lines.push(`High scroll, low conversion pages: ${stc.highScrollLowConvPages.slice(0, 3).map((p) => `${p.url} (${p.avgScroll.toFixed(0)}% scroll, ${p.conversions} conv)`).join("; ")}`);
    if (stc.lowScrollHighConvPages.length > 0) lines.push(`Low scroll, high conversion pages: ${stc.lowScrollHighConvPages.slice(0, 3).map((p) => `${p.url} (${p.avgScroll.toFixed(0)}% scroll, ${p.conversions} conv)`).join("; ")}`);
    if (sc.deviceGeo) {
      const dg = sc.deviceGeo;
      lines.push(`Device conversion rates: mobile ${(dg.deviceMix.mobile.conversionRate * 100).toFixed(1)}%, desktop ${(dg.deviceMix.desktop.conversionRate * 100).toFixed(1)}%, delta ${dg.mobileDesktopDelta >= 0 ? "+" : ""}${(dg.mobileDesktopDelta * 100).toFixed(1)}%`);
      if (dg.topMetros.length > 0) lines.push(`Top metro: ${dg.topMetros[0].city}, ${dg.topMetros[0].region} (${dg.geoConcentration.toFixed(0)}% of conversions)`);
    }
    sections.push(`SIGNAL_CENTER (behavioral telemetry — use in findings, never name the source "Signal Center" or "Site Tag" in prose):\n${lines.join("\n")}`);
  }

  if (input.bilingualAuditContext) {
    const ba = input.bilingualAuditContext;
    const nonPrimary = ba.detectedLanguages.filter((l) => l.code !== ba.primaryLanguage);
    const bilingualLines: string[] = [
      `Primary language: ${ba.primaryLanguage} | Non-primary: ${nonPrimary.map((l) => `${l.code} (${l.pageCount} pages)`).join(", ")} | Confidence: ${ba.dataConfidence}`,
      `Language coverage: ${ba.languageCoverage.perLanguage.map((l) => `${l.code} ${l.sharePercent}%`).join(", ")}`,
    ];
    for (const lang of ba.contentParity.perNonPrimaryLanguage.slice(0, 3)) {
      bilingualLines.push(`Content parity (${lang.code}): ${lang.parityPercent}% (${lang.pagesWithEquivalent} equivalent, ${lang.pagesMissingEquivalent} missing)`);
      if (lang.topMissingPages.length > 0) {
        bilingualLines.push(`  Top missing ${lang.code} pages: ${lang.topMissingPages.slice(0, 3).map((p) => p.primaryUrl).join(", ")}`);
      }
    }
    bilingualLines.push(`Hreflang: ${ba.hreflangImplementation.status} | ${ba.hreflangImplementation.pagesWithHreflang} pages with hreflang / ~${ba.hreflangImplementation.pagesShouldHaveHreflang} needed | coverage ${ba.hreflangImplementation.coveragePercent}%`);
    if (ba.hreflangImplementation.commonBugs.length > 0) {
      bilingualLines.push(`  Hreflang bugs: ${ba.hreflangImplementation.commonBugs.slice(0, 3).map((b) => b.type).join(", ")}`);
    }
    for (const lang of ba.contentQuality.perNonPrimaryLanguage.slice(0, 3)) {
      bilingualLines.push(`Content quality (${lang.code}): depth ratio ${lang.depthRatio} vs primary | CTA status: ${lang.bilingualCtaStatus}`);
    }
    if (ba.conversionPathway) {
      for (const lang of ba.conversionPathway.perLanguage.slice(0, 3)) {
        bilingualLines.push(`Conversion (${lang.code}): ${lang.sessions} sessions, ${lang.conversions} conversions, ${lang.conversionRate}% rate`);
      }
    }
    if (ba.aiCitationByLanguage) {
      for (const lang of ba.aiCitationByLanguage.perLanguage.slice(0, 3)) {
        bilingualLines.push(`AI citation (${lang.code}): ${lang.aiVisits} AI visits, ${lang.sharePercent}% of total AI traffic`);
      }
    }
    sections.push(`BILINGUAL_AUDIT (weave into relevant findings — do not label as "bilingual content audit" in prose):\n${bilingualLines.join("\n")}`);
  }

  if (input.competitorAnalysisContext) {
    const ca = input.competitorAnalysisContext;
    const compLines: string[] = [
      `Competitors detected: ${ca.detectedCompetitors.length} | Data quality: ${ca.dataQuality}`,
    ];
    for (const comp of ca.detectedCompetitors.slice(0, 3)) {
      const schemaPresent = comp.detectedSchema.filter((s) => s.present).map((s) => s.type);
      compLines.push(
        `${comp.domain}: avg rank ${comp.avgRankPosition} | DA ${comp.domainAuthority ?? "n/a"} | ${comp.referringDomains?.toLocaleString() ?? "n/a"} refs | schemas: ${schemaPresent.join(", ") || "none"} | scraped: ${comp.pagesScraped} pages`,
      );
      if (comp.topContentTopics.length > 0) {
        compLines.push(`  Topics: ${comp.topContentTopics.slice(0, 3).join("; ")}`);
      }
    }
    const topGaps = ca.competitiveGapMatrix.dimensions
      .filter((d) => d.gapMagnitude === "critical" || d.gapMagnitude === "high")
      .slice(0, 5);
    if (topGaps.length > 0) {
      compLines.push(`Top gaps (brand behind): ${topGaps.map((d) => `${d.name} (brand: ${d.brandValue}, best competitor: ${d.competitorAValue})`).join("; ")}`);
    }
    sections.push(`COMPETITOR_ANALYSIS (reference competitors by domain name in findings — do not name this section in prose):\n${compLines.join("\n")}`);
  }

  if (input.schemaOpportunityForecastContext) {
    const sf = input.schemaOpportunityForecastContext;
    const top3 = sf.prioritySchemaTypes.slice(0, 3);
    const missing = sf.prioritySchemaTypes.filter((s) => !s.present);
    const schemaLines: string[] = [
      `Coverage: ${sf.currentCoveragePercent}% of ${sf.prioritySchemaTypes.length} priority schema types present | Cumulative lift available: ${sf.cumulativeLiftEstimate}%`,
      `Top priority schemas: ${top3.map((s) => `${s.schemaType} (${s.priority}, present: ${s.present}, lift: ${s.estimatedLiftPercent}%)`).join("; ")}`,
    ];
    if (missing.length > 0) {
      schemaLines.push(`Missing schemas: ${missing.slice(0, 5).map((s) => s.schemaType).join(", ")}`);
    }
    if (sf.hasSchemaConflicts) {
      schemaLines.push(`Schema conflicts: ${sf.schemaConflicts.slice(0, 3).map((c) => `${c.url} (${c.issue})`).join("; ")}`);
    }
    sections.push(`SCHEMA_OPPORTUNITY (reference schema types and lift % in findings — do not name this section in prose):\n${schemaLines.join("\n")}`);
  }

  if (input.industryBenchmarkComparisonContext) {
    const bm = input.industryBenchmarkComparisonContext;
    const benchLines: string[] = [
      `Industry: ${bm.industryDisplayName} | Overall percentile: ${bm.overallPercentile}th`,
    ];
    for (const dim of bm.dimensions.slice(0, 4)) {
      benchLines.push(`${dim.name}: brand=${dim.brandValue} | industry avg=${dim.industryAverage} | p25=${dim.industryP25} | p75=${dim.industryP75} | standing=${dim.brandStanding}`);
    }
    sections.push(`INDUSTRY_BENCHMARK (use percentile language in findings — do not name this section in prose):\n${benchLines.join("\n")}`);
  }

  if (input.pageStrategicPassContext) {
    const psp = input.pageStrategicPassContext;
    const top5 = psp.pages.slice(0, 5);
    const passLines: string[] = [`Pages reviewed: ${psp.pages.length} | Scores: ${psp.pages.map((p) => `${p.url.replace(/^https?:\/\/[^/]+/, "") || "/"} (${p.healthScore})`).join(", ")}`];
    for (const card of top5) {
      const criticals = card.topIssues.filter((i) => i.severity === "critical").map((i) => i.issue);
      const highs = card.topIssues.filter((i) => i.severity === "high").map((i) => i.issue);
      const issueStr = [...criticals.map((i) => `CRITICAL: ${i}`), ...highs.map((i) => `high: ${i}`)].slice(0, 3).join("; ");
      passLines.push(`  ${card.url}: score=${card.healthScore} type=${card.pageType}${issueStr ? ` | issues: ${issueStr}` : ""} | rec: ${card.topRecommendation.slice(0, 100)}`);
    }
    sections.push(`PAGE_STRATEGIC_PASS (cite specific URLs and health scores in findings and actions — do not name this section in prose):\n${passLines.join("\n")}`);
  }

  if (input.aiEnginePersonalityAnalysisContext) {
    const aep = input.aiEnginePersonalityAnalysisContext;
    const topEngine = aep.topEngineId
      ? aep.engines.find((e) => e.engineId === aep.topEngineId)
      : null;
    const engineLines: string[] = [
      `Engines profiled: ${aep.engines.map((e) => e.displayName).join(", ")}`,
    ];
    if (topEngine) {
      engineLines.push(`Top performing engine (1st-party): ${topEngine.displayName} (${topEngine.firstPartyOverlay?.sessions30d ?? 0} sessions, ${topEngine.firstPartyOverlay?.conversions30d ?? 0} conversions)`);
    }
    for (const eng of aep.engines.slice(0, 3)) {
      engineLines.push(`${eng.displayName}: eAt=${eng.eAtSensitivity} recency=${eng.recencyBias} schemaPriorities=${eng.schemaPriorities.slice(0, 3).join(",")}${eng.firstPartyOverlay ? ` | sessions=${eng.firstPartyOverlay.sessions30d} conv=${eng.firstPartyOverlay.conversions30d}` : ""}`);
    }
    sections.push(`AI_ENGINE_PROFILES (use engine-specific signals in 1-2 findings max — do not enumerate all engines generically):\n${engineLines.join("\n")}`);
  }

  if (input.eeatAuditContext) {
    const ee = input.eeatAuditContext;
    const top3Gaps = ee.topGaps.slice(0, 3);
    const eeatLines: string[] = [
      `Composite E-E-A-T score: ${ee.compositeScore}/100 | Industry weight: ${ee.industryWeight}`,
      `Experience: ${ee.experienceSignals.score}/100 (bylines=${ee.experienceSignals.authorBylinesDetected}, caseStudies=${ee.experienceSignals.caseStudiesDetected}, yearsInBusiness=${ee.experienceSignals.yearsInBusinessMentioned})`,
      `Expertise: ${ee.expertiseSignals.score}/100 (credentialedAuthors=${ee.expertiseSignals.credentialedAuthors}, aboutWithCreds=${ee.expertiseSignals.hasAboutPageWithCredentials})`,
      `Authoritativeness: ${ee.authoritativenessSignals.score}/100 (orgSchema=${ee.authoritativenessSignals.organizationSchemaPresent}, sameAs=${ee.authoritativenessSignals.sameAsLinksCount}, press=${ee.authoritativenessSignals.pressMentionsDetected})`,
      `Trustworthiness: ${ee.trustworthinessSignals.score}/100 (privacyPolicy=${ee.trustworthinessSignals.privacyPolicyPresent}, aggregateRating=${ee.trustworthinessSignals.aggregateRatingSchemaPresent}, reviews=${ee.trustworthinessSignals.reviewCount})`,
    ];
    if (top3Gaps.length > 0) {
      eeatLines.push(`Top gaps: ${top3Gaps.map((g) => `${g.dimension}: ${g.gap} (lift +${g.estimatedScoreLift}pts, ${g.implementationDifficulty})`).join("; ")}`);
    }
    sections.push(`EEAT_AUDIT (reference dimension scores and topGaps in findings — do not call this "E-E-A-T Audit" in prose; use "E-E-A-T signals", "expertise framework", "authority profile"):\n${eeatLines.join("\n")}`);
  }

  if (input.brandAuthorityContext) {
    const ba = input.brandAuthorityContext;
    const authorityLines: string[] = [
      `Overall: ${ba.overall}/100 | Tier: ${ba.tier}`,
      `Summary: ${ba.narrativeSummary}`,
    ];
    ba.signals.forEach((s) => {
      authorityLines.push(`  ${s.label}: rawScore=${s.rawScore} status=${s.status} weight=${s.weight} | evidence: ${s.evidence.slice(0, 1).join(" ")}`);
    });
    if (ba.topRecommendations.length > 0) {
      authorityLines.push(`Top recommendations: ${ba.topRecommendations.slice(0, 2).join("; ")}`);
    }
    sections.push(`BRAND_AUTHORITY (reference overall score, tier, and per-signal rawScores in findings — do not call this "Brand Authority Score" in prose; use "brand authority signals", "authority profile", "citation authority analysis"):\n${authorityLines.join("\n")}`);
  }

  if (input.aiSnippetEligibilityContext) {
    const ase = input.aiSnippetEligibilityContext;
    const { aggregateStats, topOpportunities } = ase;
    const snippetLines: string[] = [
      `Queries analyzed: ${aggregateStats.totalQueriesAnalyzed} | Features present: ${aggregateStats.totalFeaturesPresent} | Eligible: ${aggregateStats.totalFeaturesEligible} | Eligibility rate: ${aggregateStats.eligibilityRatePercent}%`,
    ];
    if (topOpportunities.length > 0) {
      snippetLines.push(`Top snippet opportunities:`);
      topOpportunities.slice(0, 5).forEach((opp) => {
        snippetLines.push(`  "${opp.query}" → ${opp.feature}: ${opp.quickestPath}`);
      });
    }
    sections.push(`AI_SNIPPET_ELIGIBILITY (reference specific features and queries in findings — do not name this section; use "SERP feature analysis", "snippet eligibility"):\n${snippetLines.join("\n")}`);
  }

  if (input.snippetCaptureContext?.hasStrategy) {
    const sc = input.snippetCaptureContext;
    const captureLines: string[] = [
      `Total opportunities analyzed: ${sc.totalOpportunitiesAnalyzed} | Reformulations generated: ${sc.totalReformulationsGenerated}`,
      `Estimated monthly traffic gain: ${sc.totalEstimatedMonthlyTrafficGain} | Estimated monthly revenue gain: $${sc.totalEstimatedMonthlyRevenueGain}`,
      `Snippet type breakdown: ${Object.entries(sc.snippetTypeBreakdown).map(([k, v]) => `${k}:${v}`).join(", ")}`,
      `Top Priority Reformulations:`,
    ];
    sc.topPriorityReformulations.slice(0, 5).forEach((r, i) => {
      const target = r.newPageRecommended ? `new page: /${r.newPageSlug}` : (r.targetPageUrl ?? "existing page");
      const schema = r.captureSpec.schemaRequirements[0]?.schemaType ?? "none";
      captureLines.push(
        `  ${i + 1}. Query: "${r.query}" | Type: ${r.snippetType} | Target: ${target}`,
        `     Word count: ${r.captureSpec.wordCountTarget.min}-${r.captureSpec.wordCountTarget.max} | Probability: ${r.captureSpec.estimatedCaptureProbability} | Monthly rev gain: $${r.estimatedMonthlyRevenueGain} | Schema: ${schema}`,
      );
    });
    sections.push(`SNIPPET_CAPTURE_STRATEGY (reference total revenue gain, traffic gain, and specific queries in findings — do not name this section; use "capture specs", "prescribed reformulations", "content reformulation analysis"):\n${captureLines.join("\n")}`);
  }

  if (input.mobileFirstContext?.hasAudit) {
    const mf = input.mobileFirstContext;
    const mobileLines: string[] = [
      `Overall mobile score: ${mf.overallMobileScore}/100 | Tier: ${mf.scoreTier} | Pages evaluated: ${mf.pagesEvaluated}`,
    ];
    mf.perPageEvaluations.slice(0, 3).forEach((p) => {
      mobileLines.push(`  ${p.pageLabel} (${p.pageUrl}): score=${p.mobileScore}/100 viewport=${p.viewport.isOptimal ? "ok" : "issues"} touch_pass=${p.touchTargets.passRate}% schema=${p.mobileSchema.hasMobileSpecificSchema ? "present" : "missing"}`);
      if (p.findings.length > 0) {
        mobileLines.push(`    Top finding: [${p.findings[0].severity}] ${p.findings[0].title} -- ${p.findings[0].detail}`);
      }
    });
    if (mf.verticalSpecificNotes.length > 0) {
      mobileLines.push(`Vertical notes: ${mf.verticalSpecificNotes.join("; ")}`);
    }
    sections.push(`MOBILE_FIRST_AUDIT (reference specific mobile scores, viewport issues, touch target pass rates, and above-fold gaps in findings -- do not name this section; use "mobile usability evaluation", "mobile composition analysis", "touch target review"):\n${mobileLines.join("\n")}`);
  }

  if (input.pageSpeedContext?.hasInsights) {
    const ps = input.pageSpeedContext;
    const psLines: string[] = [
      `Performance tier: ${ps.overallScoreTier} | Pages analyzed: ${ps.pagesAnalyzed} | Estimated monthly traffic loss: ${ps.estimatedMonthlyTrafficLoss} sessions | Estimated monthly revenue loss: $${ps.estimatedMonthlyRevenueLoss}`,
      `Aggregate CWV grades: LCP=${ps.aggregateCwvGrades.LCP ?? "n/a"} INP=${ps.aggregateCwvGrades.INP ?? "n/a"} CLS=${ps.aggregateCwvGrades.CLS ?? "n/a"} FCP=${ps.aggregateCwvGrades.FCP ?? "n/a"} TTFB=${ps.aggregateCwvGrades.TTFB ?? "n/a"}`,
    ];
    ps.perPageAnalyses.slice(0, 3).forEach((p) => {
      const lcp = p.coreWebVitals.find((v) => v.metric === "LCP");
      const cls = p.coreWebVitals.find((v) => v.metric === "CLS");
      psLines.push(`  ${p.pageLabel} (${p.pageUrl}): score=${p.overallScore}/100 LCP=${lcp?.labValue ?? "n/a"}ms(${lcp?.labGrade ?? "n/a"}) CLS=${cls?.labValue ?? "n/a"}(${cls?.labGrade ?? "n/a"}) render-blocking=${p.renderBlockingResources.length}`);
    });
    if (ps.topRenderBlockingResources.length > 0) {
      psLines.push(`Top render-blocking resource: ${ps.topRenderBlockingResources[0].url} (wastedMs=${ps.topRenderBlockingResources[0].wastedMs})`);
    }
    sections.push(`PAGE_SPEED_INSIGHTS (reference specific CWV grades, render-blocking resources, and traffic/revenue loss estimates in findings -- do not name this section; use "performance evaluation", "page load analysis", "content rendering speed"):\n${psLines.join("\n")}`);
  }

  if (input.aiSourceAttributionComparisonContext) {
    const saac = input.aiSourceAttributionComparisonContext;
    if (saac.predictionVsActual.length > 0) {
      const top3Gaps = [...saac.predictionVsActual]
        .sort((a, b) => Math.abs(b.deltaPercent) - Math.abs(a.deltaPercent))
        .slice(0, 3);
      const gapLines = top3Gaps.map((d) =>
        `  ${d.dimension}: predicted=${String(d.publicSignalPrediction.value)} actual=${String(d.firstPartyActual.value)} delta=${d.deltaPercent >= 0 ? "+" : ""}${d.deltaPercent.toFixed(1)}% (${d.deltaDirection}) — ${d.explanation}`
      );
      sections.push(`AI_SOURCE_ATTRIBUTION_COMPARISON (top gaps between public signal predictions and first-party measurement — reference in findings as "public signal analysis" or "predicted vs actual measurement", never name this section):
Gap magnitude: ${saac.overallGapMagnitude} | ${saac.overallGapNarrative}
Top 3 largest prediction gaps:
${gapLines.join("\n")}`);
    }
  }

  if (input.signalCenterContext && input.signalCenterContext.dataConfidence !== "low") {
    const sc = input.signalCenterContext;
    const bridgeLines: string[] = [];
    if (sc.formAbandonment) {
      bridgeLines.push(`Form abandonment rate: ${sc.formAbandonment.abandonRate.toFixed(1)}% (${sc.formAbandonment.totalStarts} starts, ${sc.formAbandonment.totalCompletes} completes)`);
    }
    if (sc.topFrictionEvents.length > 0) {
      bridgeLines.push(`Top friction signals: ${sc.topFrictionEvents.slice(0, 3).map((e) => `${e.displayName} (${e.count}${e.topAffectedPages[0] ? " on " + e.topAffectedPages[0].url : ""})`).join("; ")}`);
    }
    if (sc.deviceGeo) {
      bridgeLines.push(`Mobile-desktop conversion delta: ${sc.deviceGeo.mobileDesktopDelta >= 0 ? "+" : ""}${(sc.deviceGeo.mobileDesktopDelta * 100).toFixed(1)}%`);
    }
    const stc = sc.scrollToConversion;
    if (stc.highScrollLowConvPages.length > 0) {
      bridgeLines.push(`High-engagement, low-conversion pages: ${stc.highScrollLowConvPages.slice(0, 3).map((p) => `${p.url} (${p.avgScroll.toFixed(0)}% scroll, ${p.conversions} conv)`).join("; ")}`);
    }
    if (bridgeLines.length > 0) {
      sections.push(`BEHAVIORAL_ACTION_BRIDGE (quantified signals for priority action rationale — refer as "behavioral telemetry" or "first-party measurement" in prose, at least one action MUST cite these):
${bridgeLines.join("\n")}`);
    }
  }

  if (input.multiTouchAiPathContext) {
    const mtp = input.multiTouchAiPathContext;
    const pathLines: string[] = [];
    const dist = mtp.pathComplexityDistribution;
    pathLines.push(`Path distribution: single-touch ${dist.singleTouch.percent}% | two-touch ${dist.twoTouch.percent}% | multi-touch ${dist.multiTouch.percent}%`);
    if (mtp.topConversionPaths.length > 0) {
      pathLines.push(`Top paths (by conversion count):`);
      mtp.topConversionPaths.slice(0, 3).forEach((p) => {
        pathLines.push(`  ${p.pathDescription} (${p.conversionCount} conversions, ${p.crossDevice ? "cross-device" : "single-device"})`);
      });
    }
    const cd = mtp.crossDeviceJourneys;
    if (cd.detected > 0) {
      pathLines.push(`Cross-device journeys: ${cd.detected} detected (${cd.percentOfConversions}% of conversions) — ${cd.typicalPattern}`);
    }
    if (mtp.topEntryChannels.length > 0) {
      pathLines.push(`Top AI entry channels: ${mtp.topEntryChannels.slice(0, 3).map((c) => `${c.channel} (${c.conversionsAsFirstTouch} first-touch)`).join(", ")}`);
    }
    sections.push(`MULTI_TOUCH_AI_PATH (first-party conversion path data — reference in findings as "conversion path analysis", "visitor journey analysis", "multi-touch attribution"; do NOT name this section):
${pathLines.join("\n")}`);
  }

  if (input.revenueAttributionContext) {
    const ra = input.revenueAttributionContext;
    const raLines: string[] = [];
    if (ra.hasRevenueTracking) {
      raLines.push(`Window: ${ra.windowDays}d | Confidence: ${ra.dataConfidence}`);
      const agg = ra.aggregateMetrics;
      raLines.push(`Total tracked: $${agg.totalRevenueTracked} across ${agg.totalConversionsWithRevenue} conversions | Avg: $${agg.avgRevenuePerConversion}/conversion | Coverage: ${agg.revenueTrackingCoveragePercent}%`);
      if (ra.revenueByChannel.length > 0) {
        raLines.push(`Top 3 channels by revenue: ${ra.revenueByChannel.slice(0, 3).map((c) => `${c.channelName} $${c.revenue} (${c.sharePercent}%)`).join(" | ")}`);
      }
      if (ra.revenueByAiEngine.length > 0) {
        raLines.push(`AI engines: ${ra.revenueByAiEngine.slice(0, 3).map((e) => `${e.engineName} $${e.revenue} (${e.shareOfTotalRevenuePercent}% of total)`).join(" | ")}`);
      }
      if (ra.topRevenuePages.length > 0) {
        raLines.push(`Top 3 revenue pages: ${ra.topRevenuePages.slice(0, 3).map((p) => `${p.pageUrl} $${p.revenue} (${p.sharePercent}%)`).join(" | ")}`);
      }
    }
    sections.push(`REVENUE_ATTRIBUTION (first-party revenue data — reference in findings as "revenue data", "attributable revenue", "channel revenue impact"; do NOT name this section):
${raLines.join("\n")}`);
  }

  if (input.predictiveConversionScoringContext) {
    const pcs = input.predictiveConversionScoringContext;
    const pcsLines: string[] = [
      `Window: ${pcs.windowDays}d | Sessions scored: ${pcs.totalScoredSessions} | Avg score: ${pcs.avgScore}/100`,
      `Distribution: Very High=${pcs.scoreDistribution.very_high} High=${pcs.scoreDistribution.high} Moderate=${pcs.scoreDistribution.moderate} Low=${pcs.scoreDistribution.low} Very Low=${pcs.scoreDistribution.very_low}`,
    ];
    const highIntentTotal = pcs.scoreDistribution.high + pcs.scoreDistribution.very_high;
    if (highIntentTotal > 0) {
      const highIntentPct = Math.round((highIntentTotal / pcs.totalScoredSessions) * 100);
      pcsLines.push(`High-intent sessions (score 61+): ${highIntentTotal} (${highIntentPct}%)`);
    }
    if (pcs.scoreByChannel.length > 0) {
      pcsLines.push(`Score by channel (top 3): ${pcs.scoreByChannel.slice(0, 3).map((c) => `${c.channelName} avg ${c.avgScore} (${c.sessionCount} sessions)`).join(" | ")}`);
    }
    if (pcs.scoreByAiEngine.length > 0) {
      pcsLines.push(`Score by AI engine: ${pcs.scoreByAiEngine.slice(0, 3).map((e) => `${e.engineName} avg ${e.avgScore} (${e.sessionCount} sessions)`).join(" | ")}`);
    }
    if (pcs.conversionRateByBand.high != null || pcs.conversionRateByBand.very_high != null) {
      const hi = pcs.conversionRateByBand.high ?? 0;
      const vhi = pcs.conversionRateByBand.very_high ?? 0;
      pcsLines.push(`Conversion rate: High-intent ${vhi}% (very high) / ${hi}% (high)`);
    }
    pcsLines.push(`Live sessions (last 30min): ${pcs.liveSessionsSnapshot.activeLast30Min} active | ${pcs.liveSessionsSnapshot.highIntentCount} high-intent | avg score ${pcs.liveSessionsSnapshot.avgScoreActive}`);
    sections.push(`PREDICTIVE_CONVERSION_SCORING (behavioral intent scoring — reference in findings as "behavioral scoring", "intent scoring", "session scoring data"; do NOT name this section):
${pcsLines.join("\n")}`);
  }

  if (input.conversionHeatMapContext?.hasClickData) {
    const chm = input.conversionHeatMapContext;
    const chmLines: string[] = [
      `Window: ${chm.windowDays}d | Confidence: ${chm.dataConfidence} | Pages analyzed: ${chm.perPage.length}`,
    ];
    // Top 5 pages summary
    for (const p of chm.perPage.slice(0, 5)) {
      let path = p.pageUrl;
      try { path = new URL(p.pageUrl).pathname; } catch { /* keep */ }
      const topRegion = p.clickDensityRegions[0];
      const topElem = p.topClickedElements[0];
      const parts: string[] = [`${p.totalClicks} clicks / ${p.totalSessions} sessions`];
      if (topRegion) parts.push(`top region: ${topRegion.regionLabel} (${topRegion.sharePercent}%${topRegion.hasConversionPath ? ", converts" : ""})`);
      if (topElem) parts.push(`top element: "${topElem.elementLabel}" (${topElem.clickCount} clicks)`);
      if (p.deadZones.length > 0) parts.push(`dead zone: ${p.deadZones[0].regionLabel} (${p.deadZones[0].missedClickRatio}:1 view-to-click)`);
      chmLines.push(`  ${path}: ${parts.join(" | ")}`);
    }
    if (chm.aggregateInsights.length > 0) {
      chmLines.push(`Cross-page patterns: ${chm.aggregateInsights.slice(0, 2).map((i) => i.insight).join(" | ")}`);
    }
    sections.push(`CONVERSION_HEAT_MAP (click density patterns — reference in findings as "click density patterns", "page-level engagement", "CTA placement analysis"; do NOT name this section):
${chmLines.join("\n")}`);
  }

  if (input.aiSearchVerificationContext) {
    const asv = input.aiSearchVerificationContext;
    const v = asv.brandVisibility;
    const pct = (r: number) => `${Math.round(r * 100)}%`;
    const asvLines: string[] = [
      `Queries checked: ${asv.queriesChecked} | Successful: ${v.queriesSuccessful}/${v.queriesRun} | Degraded: ${asv.degraded}`,
      `Overall mention rate: ${pct(v.overallMentionRate)} | Citation rate: ${pct(v.overallCitationRate)}`,
      `Branded query mention rate: ${pct(v.brandedQueryMentionRate)}`,
      `Non-branded mention rate: ${pct(v.nonBrandedMentionRate)} | Local: ${pct(v.localQueryMentionRate)} | Informational: ${pct(v.informationalMentionRate)}`,
      `Best engine: ${v.engineWithBestMentionRate} | Weakest engine: ${v.engineWithWorstMentionRate}`,
    ];
    for (const s of asv.engineSummaries) {
      asvLines.push(
        `  ${s.engineDisplayName} (${s.engineSubtitle}): mention=${pct(s.brandMentionRate)} citation=${pct(s.brandCitationRate)} successRate=${pct(s.successRate)}` +
          (s.topCompetitorsMentioned.length
            ? ` | competitors: ${s.topCompetitorsMentioned.map((c) => `${c.name}(${c.mentionCount})`).join(", ")}`
            : ""),
      );
    }
    if (asv.topCitations.length) {
      asvLines.push(`Top cited domains: ${asv.topCitations.slice(0, 5).map((c) => `${c.domain}(${c.citationCount}${c.isBrandDomain ? ",brand" : c.isCompetitorDomain ? ",competitor" : ""})`).join(", ")}`);
    }
    sections.push(`AI_SEARCH_VERIFICATION (live AI engine probe — reference as "AI search engine visibility", "conversational AI mention rate", "AI citation analysis"; do NOT name this section):
${asvLines.join("\n")}`);
  }

  if (input.lostOpportunityCalculationContext?.hasCalculation) {
    const loc = input.lostOpportunityCalculationContext;
    const agg = loc.aggregateOpportunity;
    const fmt = (n: number) => `$${n.toLocaleString()}`;
    const locLines: string[] = [
      `Industry: ${loc.industryBaselines.industryVertical} | Avg customer value: ${fmt(loc.industryBaselines.avgCustomerValue)} | Avg CVR: ${(loc.industryBaselines.avgConversionRate * 100).toFixed(1)}%`,
      `Aggregate monthly opportunity: ${fmt(agg.totalMonthlyOpportunityUsd)} | Annualized: ${fmt(agg.annualizedOpportunityUsd)} | Confidence: ${loc.confidence}`,
      `Top 3 findings by revenue impact:`,
      ...agg.topThreeMonthlyImpact.map((t, i) => `  ${i + 1}. ${t.findingTitle} — ${fmt(t.monthlyImpactUsd)}/mo (${t.timeToImplement})`),
    ];
    sections.push(`LOST_OPPORTUNITY_CALCULATION (directional revenue impact estimates — reference as "estimated opportunity cost", "directional revenue impact", "industry-benchmark-based estimates"; do NOT name this section; ALWAYS frame as ESTIMATE; omit dollar figures for low-confidence findings):
${locLines.join("\n")}`);
  }

  if (input.liveVisualAnnotationsContext?.hasAnnotations) {
    const lva = input.liveVisualAnnotationsContext;
    const agg = lva.aggregateInsights;
    const lines: string[] = [
      `Pages captured: ${lva.perPageAnnotations.length} | Total annotations: ${agg.totalAnnotations} | Pages with critical issues: ${agg.pagesWithCriticalIssues}`,
    ];
    for (const p of lva.perPageAnnotations) {
      lines.push(`${p.pageLabel} (${p.pageUrl}): ${p.annotationCount} annotations — critical=${p.severityBreakdown.critical} high=${p.severityBreakdown.high} moderate=${p.severityBreakdown.moderate} positive=${p.severityBreakdown.positive}`);
      for (const ann of p.annotations.slice(0, 5)) {
        lines.push(`  Issue #${ann.number} [${ann.severity}]: ${ann.shortLabel} — ${ann.description.slice(0, 120)}`);
      }
    }
    if (agg.weakestPage) {
      lines.push(`Weakest page: ${agg.weakestPage.label} — ${agg.weakestPage.criticalCount} critical, ${agg.weakestPage.highCount} high-severity issues`);
    }
    sections.push(`LIVE_VISUAL_ANNOTATIONS (page-level visual inspection — reference as "visual analysis", "page-level inspection", "above-fold composition"; do NOT name this section):
${lines.join("\n")}`);
  }

  if (input.competitorVisualComparisonContext?.hasComparison) {
    const cvc = input.competitorVisualComparisonContext;
    const lines: string[] = [];
    for (const comp of cvc.comparisons) {
      lines.push(`vs ${comp.competitorName}: ${comp.homepageComparison.differences.length} structural differences identified`);
      for (const d of comp.homepageComparison.differences) {
        lines.push(`  Difference #${d.number} [${d.impact}] ${d.category}: brand="${d.brandObservation}" competitor="${d.competitorObservation}"`);
      }
    }
    if (cvc.topDifferenceCategories.length > 0) {
      lines.push(`Most common gaps: ${cvc.topDifferenceCategories.map((c) => c.category).join("; ")}`);
    }
    sections.push(`COMPETITOR_VISUAL_COMPARISON (side-by-side competitor review — reference as "visual comparison", "side-by-side competitor review", "structural competitor gap"; do NOT name this section):
${lines.join("\n")}`);
  }

  if (input.webVitalsContext && input.webVitalsContext.length > 0) {
    const lcpRating = (ms: number) => ms < 2500 ? "Good" : ms < 4000 ? "Needs Improvement" : "Poor";
    const inpRating = (ms: number) => ms < 200 ? "Good" : ms < 500 ? "Needs Improvement" : "Poor";
    const clsRating = (v: number) => v < 0.1 ? "Good" : v < 0.25 ? "Needs Improvement" : "Poor";
    const ttfbRating = (ms: number) => ms < 800 ? "Good" : ms < 1800 ? "Needs Improvement" : "Poor";

    const vitalsLines = input.webVitalsContext.map((e) => {
      const parts: string[] = [];
      if (e.lcp75 != null) parts.push(`LCP: ${(e.lcp75 / 1000).toFixed(2)}s (${lcpRating(e.lcp75)})`);
      if (e.inp75 != null) parts.push(`INP: ${e.inp75.toFixed(0)}ms (${inpRating(e.inp75)})`);
      if (e.cls75 != null) parts.push(`CLS: ${e.cls75.toFixed(3)} (${clsRating(e.cls75)})`);
      if (e.ttfb75 != null) parts.push(`TTFB: ${e.ttfb75.toFixed(0)}ms (${ttfbRating(e.ttfb75)})`);
      return `  ${e.url} (${e.samples} samples) — ${parts.join(", ")}`;
    });
    sections.push(`CORE_WEB_VITALS_P75 (real user measurement, trailing 30 days — cite exact values and ratings in findings):
Thresholds: LCP Good <2.5s / NI 2.5-4s / Poor >4s | INP Good <200ms / NI 200-500ms / Poor >500ms | CLS Good <0.1 / NI 0.1-0.25 / Poor >0.25 | TTFB Good <800ms / NI 800-1800ms / Poor >1800ms
${vitalsLines.join("\n")}`);
  }

  if (input.crawlStatsContext) {
    const ctx = input.crawlStatsContext;
    const lines: string[] = [];
    if (ctx.missingH1Urls.length > 0) lines.push(`Pages missing H1 (${ctx.missingH1Urls.length}): ${ctx.missingH1Urls.slice(0, 8).join(", ")}`);
    if (ctx.missingMetaDescUrls.length > 0) lines.push(`Pages missing meta description (${ctx.missingMetaDescUrls.length}): ${ctx.missingMetaDescUrls.slice(0, 8).join(", ")}`);
    if (ctx.thinContentUrls.length > 0) lines.push(`Thin content pages <300 words (${ctx.thinContentUrls.length}): ${ctx.thinContentUrls.slice(0, 8).join(", ")}`);
    if (ctx.missingSchemaUrls.length > 0) lines.push(`Pages missing schema markup (${ctx.missingSchemaUrls.length}): ${ctx.missingSchemaUrls.slice(0, 8).join(", ")}`);
    if (lines.length > 0) {
      sections.push(`CRAWL_STATS (use these specific URLs in affectedUrls for relevant findings):
${lines.join("\n")}`);
    }
  }

  if (input.realUserTechContext && input.realUserTechContext.patternCount > 0) {
    const rtc = input.realUserTechContext;
    sections.push(`REAL_USER_TECH_CONTEXT (inject 1-2 sentences into the executive summary only; do NOT create findings from this — findings are generated separately):
Beyond SEO, real-user behavioral data surfaced ${rtc.patternCount} technical issue${rtc.patternCount !== 1 ? "s" : ""} including: ${rtc.topFinding}. These are not visible to conventional SEO tools and require active remediation by the engineering team.`);
  }

  if (input.multilingualContext && input.multilingualContext.findingCount > 0) {
    const mc = input.multilingualContext;
    sections.push(`MULTILINGUAL_CONTEXT (inject 1 sentence into the executive summary only; do NOT create findings from this — findings are generated separately):
Multilingual coverage analysis surfaced ${mc.findingCount} finding${mc.findingCount !== 1 ? "s" : ""} including: ${mc.topFinding}. These affect search engine language targeting and may suppress search visibility for non-primary language audiences.`);
  }

  if (input.complianceContext && input.complianceContext.findingCount > 0) {
    const cc = input.complianceContext;
    const hipaaNote = cc.isHipaaRelevant ? " including HIPAA-relevant surfaces on this medical site" : "";
    sections.push(`COMPLIANCE_CONTEXT (inject 1 sentence into the executive summary only; do NOT create findings from this — findings are generated separately):
Compliance analysis surfaced ${cc.findingCount} finding${cc.findingCount !== 1 ? "s" : ""}${hipaaNote}: ${cc.topFinding}. These findings may have legal exposure beyond SEO impact and warrant review by appropriate compliance counsel.`);
  }

  if (input.ecommerceContext && input.ecommerceContext.findingCount > 0) {
    const ec = input.ecommerceContext;
    sections.push(`ECOMMERCE_CONTEXT (inject 1 sentence into the executive summary only; do NOT create findings from this — findings are generated separately):
E-commerce SEO analysis surfaced ${ec.findingCount} finding${ec.findingCount !== 1 ? "s" : ""} including: ${ec.topFinding}. These directly affect product visibility in Shopping results and commercial search queries.`);
  }

  if (input.b2bContext && input.b2bContext.findingCount > 0) {
    const bc = input.b2bContext;
    sections.push(`B2B_CONTEXT (inject 1 sentence into the executive summary only; do NOT create findings from this — findings are generated separately):
B2B SEO analysis surfaced ${bc.findingCount} finding${bc.findingCount !== 1 ? "s" : ""} including: ${bc.topFinding}. These affect pipeline generation and sales-cycle visibility across funnel stages.`);
  }

  if (input.degradedMode) {
    sections.push(`DEGRADED_MODE_CONTEXT:
Site crawl was blocked. You have NO technical SEO data, page content, meta tags, or schema data.
Available data: AI attribution, conversions, and behavioral sessions from Site Tag telemetry.
Write a telemetry-led audit. Open the executive summary with what first-party data shows.
Surface AI referral trends, conversion attribution, and behavioral patterns as primary findings.
Include exactly one low-priority finding about the crawler block.`);
  }

  return sections.join("\n\n");
}

// ─── Narrative call (Sonnet + prompt caching) ─────────────────────────────────

interface NarrativeParsedOutput {
  displayName?: string;
  executiveSummary: string;
  prioritizedFindings: PrioritizedFindingRaw[];
  additionalFindings: Array<{ title: string; assessment: string }>;
  summaryThemes: Array<{ theme: string; observation: string }>;
  priorityActions: Array<{
    title: string;
    impact: string;
    effort: string;
    rationale: string;
    phaseAssignment?: string;
    effortHours?: number;
    expectedLiftPoints?: number;
    implementation?: {
      type?: string;
      codeSnippet?: string | null;
      copyRecommendation?: string | null;
      implementationSteps?: string[];
    };
  }>;
  aiSearchPerformance?: { narrative: string };
  executiveBrief?: import("../types").ExecutiveBrief;
}

interface PrioritizedFindingRaw {
  rank: number;
  title: string;
  priority: "critical" | "high" | "medium" | "low";
  whatsWorking: string;
  whereItFallsShort: string;
  recommendedNextSteps: string[];
  affectedUrls?: string[];
  whatFirstPartyDataWouldAdd?: string;
  dataSourceWeave?: string[];
}

function parseNarrative(raw: string): NarrativeParsedOutput {
  const cleaned = raw
    .replace(/^```(?:json)?\n?/m, "")
    .replace(/\n?```$/m, "")
    .trim();

  let parsed: NarrativeParsedOutput;
  try {
    parsed = JSON.parse(cleaned) as NarrativeParsedOutput;
  } catch {
    // JSON.parse failed — attempt structural repair before giving up
    parsed = JSON.parse(jsonrepair(cleaned)) as NarrativeParsedOutput;
  }

  if (
    typeof parsed.executiveSummary !== "string" ||
    !Array.isArray(parsed.prioritizedFindings) ||
    !Array.isArray(parsed.priorityActions)
  ) {
    throw new Error("Narrative output missing required fields");
  }
  return parsed;
}

async function callNarrative(
  systemPromptText: string,
  userPrompt: string,
  healthScore: number,
  website: string,
): Promise<NarrativeParsedOutput> {
  if (!ANTHROPIC_API_KEY) throw new AuditSynthesisError("AI synthesis service not configured");

  let lastErr: unknown;

  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      const t0 = Date.now();
      const res = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        signal: AbortSignal.timeout(NARRATIVE_TIMEOUT_MS),
        headers: {
          "x-api-key": ANTHROPIC_API_KEY,
          "anthropic-version": "2023-06-01",
          "anthropic-beta": "prompt-caching-2024-07-31",
          "content-type": "application/json",
        },
        body: JSON.stringify({
          model: NARRATIVE_MODEL,
          max_tokens: NARRATIVE_MAX_TOKENS,
          temperature: TEMPERATURE,
          system: [
            {
              type: "text",
              text: systemPromptText,
              cache_control: { type: "ephemeral" },
            },
          ],
          messages: [
            {
              role: "user",
              content: userPrompt + "\n\nWrite the narrative audit section now. Return only the JSON object.",
            },
          ],
        }),
      });

      if (!res.ok) {
        const body = await res.text().catch(() => "");
        const httpErr = Object.assign(
          new Error(`Narrative API ${res.status}: ${body.slice(0, 200)}`),
          { status: res.status },
        );
        // 4xx (except 429 rate-limit) are non-retryable
        if (res.status >= 400 && res.status < 500 && res.status !== 429) {
          throw new SynthesizerError(httpErr);
        }
        throw httpErr;
      }

      const data = (await res.json()) as {
        content: Array<{ type: string; text: string }>;
        usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number };
      };

      const elapsedMs = Date.now() - t0;
      const outputTokens = data.usage?.output_tokens ?? 0;
      const truncated = outputTokens >= NARRATIVE_MAX_TOKENS - 10;
      console.log(
        `[synthesizer-narrative] attempt=${attempt} elapsed=${elapsedMs}ms tokens in=${data.usage?.input_tokens} out=${outputTokens}${truncated ? " TRUNCATED" : ""} cache_read=${data.usage?.cache_read_input_tokens ?? 0} cache_write=${data.usage?.cache_creation_input_tokens ?? 0}`,
      );

      const text = data.content.find((c) => c.type === "text")?.text ?? "";
      try {
        return parseNarrative(text);
      } catch (parseErr) {
        // Parse failures are deterministic (truncation, schema drift) — a second API call
        // with the same max_tokens ceiling will produce the same truncated output.
        // Throw immediately so the audit fails cleanly with a retry CTA.
        console.error(
          `[synthesizer-narrative] parse failed (deterministic, not retrying): ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
        );
        throw new SynthesizerError(parseErr instanceof Error ? parseErr : new Error(String(parseErr)));
      }
    } catch (err) {
      if (err instanceof SynthesizerError || err instanceof AuditSynthesisError) throw err;
      lastErr = err;
      // AbortError (from AbortSignal.timeout) is intentionally NOT retryable.
      // The model is still generating — a retry at the same timeout will hit the
      // same wall and burn another 140s we cannot afford before the 300s ceiling.
      if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
        throw new SynthesizerTimeoutError(err, attempt);
      }
      const isRetryable =
        (err instanceof Error && ((err as NodeJS.ErrnoException).code === "ECONNRESET" || (err as NodeJS.ErrnoException).code === "ETIMEDOUT")) ||
        ((err as { status?: number }).status ?? 0) >= 500;
      if (isRetryable && attempt < 2) {
        console.warn(
          `[synthesizer-narrative] retrying attempt=2 reason=${err instanceof Error ? err.message : String(err)}`,
        );
        continue;
      }
      throw new SynthesizerError(err);
    }
  }

  throw new SynthesizerError(lastErr);
}

// ─── Structured call (Haiku — methodology, industry framework, quick wins) ────

interface StructuredParsedOutput {
  methodology?: { factors: Array<{ factor: string; lookedAt: string }> };
  industryFramework?: {
    industry: string;
    subVertical: string;
    audienceFraming: string;
    relevantFrameworks: string[];
  };
  quickWins: Array<{ title: string; estimatedTime: string }>;
}

const STRUCTURED_FALLBACK: StructuredParsedOutput = { quickWins: [] };

function parseStructured(raw: string): StructuredParsedOutput {
  const cleaned = raw
    .replace(/^```(?:json)?\n?/m, "")
    .replace(/\n?```$/m, "")
    .trim();

  let parsed: StructuredParsedOutput;
  try {
    parsed = JSON.parse(cleaned) as StructuredParsedOutput;
  } catch {
    parsed = JSON.parse(jsonrepair(cleaned)) as StructuredParsedOutput;
  }

  if (!Array.isArray(parsed.quickWins)) throw new Error("Structured output missing quickWins");
  return parsed;
}

async function callStructured(
  systemPromptText: string,
  userPrompt: string,
): Promise<StructuredParsedOutput> {
  if (!ANTHROPIC_API_KEY) return STRUCTURED_FALLBACK;

  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      const t0 = Date.now();
      const res = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        signal: AbortSignal.timeout(STRUCTURED_TIMEOUT_MS),
        headers: {
          "x-api-key": ANTHROPIC_API_KEY,
          "anthropic-version": "2023-06-01",
          "content-type": "application/json",
        },
        body: JSON.stringify({
          model: STRUCTURED_MODEL,
          max_tokens: STRUCTURED_MAX_TOKENS,
          temperature: 0,
          system: systemPromptText,
          messages: [
            {
              role: "user",
              content: userPrompt + "\n\nWrite the structured classification section now. Return only the JSON object.",
            },
          ],
        }),
      });

      if (!res.ok) {
        console.warn(`[synthesizer-structured] HTTP ${res.status} on attempt ${attempt}`);
        if (attempt < 2) continue;
        return STRUCTURED_FALLBACK;
      }

      const data = (await res.json()) as {
        content: Array<{ type: string; text: string }>;
        usage?: { input_tokens: number; output_tokens: number };
      };

      const elapsedMs = Date.now() - t0;
      console.log(
        `[synthesizer-structured] attempt=${attempt} elapsed=${elapsedMs}ms tokens in=${data.usage?.input_tokens} out=${data.usage?.output_tokens}`,
      );

      const text = data.content.find((c) => c.type === "text")?.text ?? "";
      try {
        return parseStructured(text);
      } catch (parseErr) {
        // Parse failures are deterministic — don't retry, use fallback (non-fatal for structured call)
        console.warn(
          `[synthesizer-structured] parse failed (non-retryable): ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
        );
        return STRUCTURED_FALLBACK;
      }
    } catch (err) {
      // AbortError is not retryable — structured call is non-fatal, fall back immediately.
      const isRetryable =
        !(err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) &&
        ((err instanceof Error && ((err as NodeJS.ErrnoException).code === "ECONNRESET" || (err as NodeJS.ErrnoException).code === "ETIMEDOUT")) ||
        ((err as { status?: number }).status ?? 0) >= 500);
      if (isRetryable && attempt < 2) {
        console.warn(
          `[synthesizer-structured] retrying attempt=2 reason=${err instanceof Error ? err.message : String(err)}`,
        );
        continue;
      }
      console.warn(
        `[synthesizer-structured] failed after ${attempt} attempt(s), using fallback: ${err instanceof Error ? err.message : String(err)}`,
      );
      return STRUCTURED_FALLBACK;
    }
  }

  return STRUCTURED_FALLBACK;
}

// ─── Copy sanitization ────────────────────────────────────────────────────────

const PAGE_COUNT_RE = /\b\d+\s+pages?\b/gi;
const EM_DASH_RE = /\s*—\s*/g;

function applyCopyFixes(s: string | null | undefined): string {
  return (s ?? "").replace(PAGE_COUNT_RE, "audited pages").replace(EM_DASH_RE, ", ");
}

function ensureTitleCapitalized(s: string): string {
  if (!s) return s;
  const first = s.charCodeAt(0);
  if (first >= 97 && first <= 122) return s[0].toUpperCase() + s.slice(1);
  return s;
}

function sanitizeExecutiveBrief(brief: import("../types").ExecutiveBrief): import("../types").ExecutiveBrief {
  return {
    ...brief,
    headline: {
      ...brief.headline,
      narrative: applyCopyFixes(brief.headline.narrative),
      keyMetric: applyCopyFixes(brief.headline.keyMetric),
    },
    crossCuttingThemes: brief.crossCuttingThemes.map((t) => ({
      ...t,
      description: applyCopyFixes(t.description),
    })),
    criticalFinding: {
      ...brief.criticalFinding,
      framing: applyCopyFixes(brief.criticalFinding.framing),
      affectedBusinessOutcome: applyCopyFixes(brief.criticalFinding.affectedBusinessOutcome),
      primaryRemediation: applyCopyFixes(brief.criticalFinding.primaryRemediation),
    },
    topFindings: brief.topFindings.map((f) => ({
      ...f,
      impactSummary: applyCopyFixes(f.impactSummary),
      primaryRecommendation: applyCopyFixes(f.primaryRecommendation),
    })),
    topQuickWins: brief.topQuickWins.map((w) => ({
      ...w,
      implementationNote: applyCopyFixes(w.implementationNote),
      whyFirst: applyCopyFixes(w.whyFirst),
    })),
    ninetyDayOutlook: {
      ...brief.ninetyDayOutlook,
      confidenceNote: applyCopyFixes(brief.ninetyDayOutlook.confidenceNote),
    },
    closingPage: {
      ...brief.closingPage,
      ...(brief.closingPage.behavioralHighlights && {
        behavioralHighlights: {
          ...brief.closingPage.behavioralHighlights,
          narrative: applyCopyFixes(brief.closingPage.behavioralHighlights.narrative),
          topInsights: brief.closingPage.behavioralHighlights.topInsights.map((i) => ({
            ...i,
            insight: applyCopyFixes(i.insight),
          })),
        },
      }),
      ...(brief.closingPage.firstPartyPreview && {
        firstPartyPreview: {
          ...brief.closingPage.firstPartyPreview,
          narrative: applyCopyFixes(brief.closingPage.firstPartyPreview.narrative),
        },
      }),
    },
  };
}

function sanitizeNarrativeCopy(parsed: NarrativeParsedOutput, website: string): NarrativeParsedOutput {
  const allTextBefore = [
    parsed.executiveSummary,
    parsed.aiSearchPerformance?.narrative ?? "",
    ...parsed.prioritizedFindings.flatMap((f) => [
      f.title, f.whatsWorking, f.whereItFallsShort, ...(f.recommendedNextSteps ?? []),
    ]),
    ...(parsed.additionalFindings ?? []).map((f) => f.assessment),
    ...(parsed.summaryThemes ?? []).map((t) => t.observation),
    ...parsed.priorityActions.map((a) => `${a.title ?? ""} ${a.rationale ?? ""}`),
  ].join("\n");

  const pageCountsBefore = allTextBefore.match(PAGE_COUNT_RE)?.length ?? 0;
  const emDashesBefore = allTextBefore.match(EM_DASH_RE)?.length ?? 0;

  const result: NarrativeParsedOutput = {
    ...parsed,
    executiveSummary: applyCopyFixes(parsed.executiveSummary),
    aiSearchPerformance: parsed.aiSearchPerformance
      ? { narrative: applyCopyFixes(parsed.aiSearchPerformance.narrative) }
      : undefined,
    prioritizedFindings: parsed.prioritizedFindings.map((f) => ({
      ...f,
      title: ensureTitleCapitalized(applyCopyFixes(f.title)),
      whatsWorking: applyCopyFixes(f.whatsWorking),
      whereItFallsShort: applyCopyFixes(f.whereItFallsShort),
      recommendedNextSteps: (f.recommendedNextSteps ?? []).map(applyCopyFixes),
      ...(f.whatFirstPartyDataWouldAdd && {
        whatFirstPartyDataWouldAdd: applyCopyFixes(f.whatFirstPartyDataWouldAdd),
      }),
    })),
    additionalFindings: (parsed.additionalFindings ?? []).map((f) => ({
      ...f,
      title: ensureTitleCapitalized(f.title),
      assessment: applyCopyFixes(f.assessment),
    })),
    summaryThemes: (parsed.summaryThemes ?? []).map((t) => ({
      ...t,
      observation: applyCopyFixes(t.observation),
    })),
    executiveBrief: parsed.executiveBrief ? sanitizeExecutiveBrief(parsed.executiveBrief) : undefined,
    priorityActions: parsed.priorityActions.map((a) => ({
      ...a,
      title: ensureTitleCapitalized(applyCopyFixes(a.title)),
      rationale: applyCopyFixes(a.rationale),
      ...(a.implementation && { implementation: a.implementation }),
    })),
  };

  const pageCountsAfter = [result.executiveSummary, ...result.prioritizedFindings.map((f) => f.whereItFallsShort)]
    .join("\n").match(PAGE_COUNT_RE)?.length ?? 0;

  console.log(
    `[synthesizer-copy] pageCountsBefore=${pageCountsBefore} pageCountsAfter=${pageCountsAfter} emDashesBefore=${emDashesBefore} for ${website}`,
  );
  if (pageCountsAfter > 0) {
    console.warn(`[synthesizer-copy] WARN: ${pageCountsAfter} page-count reference(s) survived sanitization for ${website}`);
  }

  return result;
}

// ─── First-party enrichment assembly ─────────────────────────────────────────

function buildBehavioralOverlay(
  pageUrl: string,
  behavioralContext: AuditInput["behavioralContext"],
): BehavioralOverlay | undefined {
  if (!behavioralContext) return undefined;
  const key = normUrl(pageUrl);
  const match = behavioralContext.pages.find((p) => normUrl(p.url) === key);
  if (!match) return undefined;

  const estimatedLostConversionValue =
    match.bounceRate != null ? match.conversionValue * match.bounceRate : 0;

  return {
    bounceRate: match.bounceRate,
    avgSessionDuration: match.avgSessionDuration,
    conversions: match.conversionCount,
    conversionValue: match.conversionValue,
    estimatedLostConversionValue,
  };
}

function buildAiSearchPerformance(
  narrative: string,
  aiAttributionContext: NonNullable<AuditInput["aiAttributionContext"]>,
): AiSearchPerformance {
  const trendVsTrailing90d =
    aiAttributionContext.trendRatio != null
      ? (aiAttributionContext.trendRatio - 1) * 100
      : null;

  const estimatedAttributedRevenue = aiAttributionContext.topPages.reduce(
    (sum, p) => sum + p.attributedRevenue,
    0,
  );

  return {
    narrative,
    totalReferrals30d: aiAttributionContext.totalAiReferrals,
    trendVsTrailing90d,
    topAttributedPages: aiAttributionContext.topPages.map((p) => ({
      url: p.url,
      aiVisits: p.aiVisits,
      conversions: p.conversions,
    })),
    topEngines: aiAttributionContext.topEngines,
    estimatedAttributedRevenue,
    ...(aiAttributionContext.referralsByDay && {
      referralsByDay: aiAttributionContext.referralsByDay,
    }),
  };
}

// ─── 3rd-party teaser (deterministic) ────────────────────────────────────────

function buildLockedFirstPartyTeaser(
  findings: NarrativeParsedOutput["prioritizedFindings"],
): NonNullable<import("../types").SeoSection["lockedFirstPartyTeaser"]> {
  const hasCritical = findings.some((f) => f.priority === "critical");
  const hasConversionFinding = findings.some(
    (f) => /conver|revenue|lead|contact|cta/i.test(f.title + " " + f.whereItFallsShort),
  );
  const hasTechnicalFinding = findings.some((f) => f.priority === "critical" || f.priority === "high");

  const whatYouAreMissing: string[] = [
    "Which pages visitors bounce from before contacting you — and how much revenue that represents",
    "Which AI engines are sending traffic and whether those visitors convert",
  ];

  if (hasCritical || hasTechnicalFinding) {
    whatYouAreMissing.push("Real Core Web Vitals at p75 from actual user devices on your network");
  }
  if (hasConversionFinding) {
    whatYouAreMissing.push("Revenue directly attributed to organic and AI search — by page and by engine");
  } else {
    whatYouAreMissing.push("Conversion value attributed to each traffic source — by landing page");
  }

  const proofPointMetric = hasCritical
    ? "Brands with Site Tag installed discover an average of 2.4x more AI-attributed conversions than estimated from backlinks alone"
    : "Site Tag users surface behavioral data that changes the priority order of findings in 7 out of 10 audits";

  return {
    whatYouAreMissing: whatYouAreMissing.slice(0, 4),
    siteTagInstallUrl: "/settings/site-tag",
    proofPointMetric,
  };
}

// ─── Main export ──────────────────────────────────────────────────────────────

export async function synthesizeSeoSection(
  input: AuditInput,
  findings: SeoFindings,
  healthScore: number,
  scoringBreakdown: { critical: number; high: number; medium: number; low: number },
  contentSamples: ContentSample[],
  enrichment?: AuditEnrichment,
): Promise<SeoSection> {
  // If no crawl data is available, require telemetry or content samples to proceed.
  // Throwing here lets audit-runner classify this as synthesizer_error and surface
  // a retry CTA instead of completing with zero-value hardcoded content.
  if (findings.metaTitles.total === 0) {
    const hasTelemetry = !!(
      input.aiAttributionContext ||
      (input.behavioralContext?.pages.length ?? 0) > 0 ||
      contentSamples.length > 0
    );
    if (!hasTelemetry) {
      console.warn(
        `[audit-synthesizer] 0 pages and no telemetry for ${input.website} — failing cleanly`,
      );
      throw new SynthesizerError(new Error("No crawl data and no telemetry — cannot synthesize"));
    }
    console.log(`[audit-synthesizer] degraded synthesis: no crawl data but telemetry present for ${input.website}`);
  }

  const hasFirstParty = !!(
    input.brandContext ||
    input.behavioralContext ||
    input.aiAttributionContext
  );
  const industry = input.inferredIndustry ?? "other";

  const narrativeSystemPrompt = buildNarrativeSystemPrompt(hasFirstParty, industry, input.degradedMode, enrichment?.industryContext);
  const structuredSystemPrompt = buildStructuredSystemPrompt(industry);
  const userPrompt = buildUserPrompt(input, findings, contentSamples, enrichment);

  const t0 = Date.now();

  // Run narrative (Sonnet + caching) and structured (Haiku) in parallel
  let narrative: NarrativeParsedOutput;
  let structured: StructuredParsedOutput;

  try {
    [narrative, structured] = await Promise.all([
      callNarrative(narrativeSystemPrompt, userPrompt, healthScore, input.website),
      callStructured(structuredSystemPrompt, userPrompt),
    ]);
  } catch (err) {
    // Typed errors (timeout/synthesis) propagate as-is so audit-runner can classify them
    if (err instanceof SynthesizerTimeoutError || err instanceof SynthesizerError) throw err;
    throw new AuditSynthesisError("Insight generation failed", err);
  }

  console.log(
    `[synthesizer] parallel calls complete totalMs=${Date.now() - t0} firstParty=${hasFirstParty} industry=${industry}`,
  );

  // Apply copy safety net to narrative output
  narrative = sanitizeNarrativeCopy(narrative, input.website);

  // ── Assemble first-party enrichments code-side ──────────────────────────────
  const totalBehavioral = input.behavioralContext?.pages.length ?? 0;

  let aiSearchPerformance: AiSearchPerformance | undefined;
  if (input.aiAttributionContext) {
    // Build from attribution data unconditionally; use model-written narrative if available.
    // Without this, aiSearchPerformance is absent from SeoSection whenever the model omits
    // the aiSearchPerformance field, even though the raw attribution numbers exist.
    const aiNarrativeText = narrative.aiSearchPerformance?.narrative ?? "";
    aiSearchPerformance = buildAiSearchPerformance(aiNarrativeText, input.aiAttributionContext);
  }

  const brandEcho = input.brandContext
    ? {
        brandName: input.brandContext.brandName,
        services: input.brandContext.services,
        locations: input.brandContext.locations,
      }
    : undefined;

  // ── Derive backward-compat keyFindings from prioritizedFindings ────────────
  const keyFindings = narrative.prioritizedFindings.slice(0, 6).map((f) => {
    const base = {
      title: String(f.title),
      detail: String(f.whereItFallsShort),
    };
    const urlMatch =
      f.whereItFallsShort.match(/https?:\/\/[^\s,)"]+/) ??
      f.whatsWorking.match(/https?:\/\/[^\s,)"]+/);
    if (!urlMatch) return base;
    const overlay = buildBehavioralOverlay(urlMatch[0], input.behavioralContext);
    return overlay ? { ...base, behavioralOverlay: overlay } : base;
  });

  const matchedBehavioral = keyFindings.filter((f) => "behavioralOverlay" in f).length;

  console.log(
    `[audit-synthesizer] firstParty=${hasFirstParty} industry=${industry} aiSection=${!!aiSearchPerformance} behavioralCoverage=${matchedBehavioral}/${totalBehavioral}`,
  );

  const methodology: AuditMethodology | undefined = structured.methodology?.factors?.length
    ? { factors: structured.methodology.factors }
    : undefined;

  const industryFramework: IndustryClassification | undefined = structured.industryFramework
    ? {
        industry: String(structured.industryFramework.industry),
        subVertical: String(structured.industryFramework.subVertical),
        audienceFraming: String(structured.industryFramework.audienceFraming),
        relevantFrameworks: Array.isArray(structured.industryFramework.relevantFrameworks)
          ? structured.industryFramework.relevantFrameworks.map(String)
          : [],
      }
    : undefined;

  const quickWins = structured.quickWins.slice(0, 5).map((w) => ({
    title: applyCopyFixes(String(w.title)),
    estimatedTime: String(w.estimatedTime),
  }));

  return {
    healthScore,
    ...(narrative.displayName && { displayName: String(narrative.displayName) }),
    // Backward-compat fields
    narrative: narrative.executiveSummary,
    keyFindings,
    priorityActions: narrative.priorityActions.slice(0, 8).map((a) => ({
      title: String(a.title),
      impact: (["high", "medium", "low"].includes(a.impact) ? a.impact : "medium") as
        | "high"
        | "medium"
        | "low",
      effort: (["hour", "day", "week"].includes(a.effort) ? a.effort : "day") as
        | "hour"
        | "day"
        | "week",
      rationale: String(a.rationale),
      ...(a.phaseAssignment && (["week-1", "weeks-2-4", "month-2", "month-3"] as string[]).includes(a.phaseAssignment) && {
        phaseAssignment: a.phaseAssignment as "week-1" | "weeks-2-4" | "month-2" | "month-3",
      }),
      ...(typeof a.effortHours === "number" && { effortHours: a.effortHours }),
      ...(typeof a.expectedLiftPoints === "number" && { expectedLiftPoints: a.expectedLiftPoints }),
      ...(a.implementation && {
        implementation: {
          ...(a.implementation.type && {
            type: (["Schema", "Meta", "CTA", "Content", "Performance", "Redirect"].includes(a.implementation.type)
              ? a.implementation.type
              : "Content") as import("../types").SeoActionImplementation["type"],
          }),
          ...(a.implementation.codeSnippet != null && { codeSnippet: String(a.implementation.codeSnippet) }),
          ...(a.implementation.copyRecommendation != null && { copyRecommendation: String(a.implementation.copyRecommendation) }),
          ...(a.implementation.implementationSteps?.length && {
            implementationSteps: a.implementation.implementationSteps.map(String),
          }),
        },
      }),
    })),
    quickWins,
    scoringBreakdown,
    // Consulting-grade fields
    prioritizedFindings: narrative.prioritizedFindings.map((f) => ({
      rank: f.rank,
      title: f.title,
      priority: f.priority,
      whatsWorking: f.whatsWorking,
      whereItFallsShort: f.whereItFallsShort,
      recommendedNextSteps: f.recommendedNextSteps ?? [],
      ...(f.affectedUrls && { affectedUrls: f.affectedUrls }),
      ...(f.whatFirstPartyDataWouldAdd && { whatFirstPartyDataWouldAdd: f.whatFirstPartyDataWouldAdd }),
      ...(f.dataSourceWeave && {
        dataSourceWeave: f.dataSourceWeave.filter(
          (t): t is import("../types").DataSourceTag =>
            ["crawl", "content", "authority", "serp", "ai-surface", "framework"].includes(t),
        ),
      }),
    })),
    additionalFindings: narrative.additionalFindings ?? [],
    summaryThemes: narrative.summaryThemes ?? [],
    ...(narrative.executiveBrief && { executiveBrief: narrative.executiveBrief }),
    inferredIndustry: industry,
    // Methodology + industry framework (from structured call)
    ...(methodology && { methodology }),
    ...(industryFramework && { industryFramework }),
    // First-party enrichments
    ...(aiSearchPerformance && { aiSearchPerformance }),
    ...(brandEcho && { brandEcho }),
    // 3rd-party teaser (absent for 1st-party audits)
    ...(!hasFirstParty && {
      lockedFirstPartyTeaser: buildLockedFirstPartyTeaser(narrative.prioritizedFindings),
    }),
  };
}
