from __future__ import annotations

import hashlib
import json
import re
from pathlib import Path
from typing import Any

ROOT_DIR = Path(__file__).resolve().parents[1]
PROMPTS_DIR = ROOT_DIR / "Prompts"

MODULE_FILES: dict[str, str] = {
    "01": "01_ITERFACT_RESEARCH_AND_DATA_FOUNDATION_PROMPT.md",
    "02": "02_ITERFACT_DATA_INGESTION_AND_TRANSFORMATION_PROMPT.md",
    "03": "03_ITERFACT_DASHBOARD_ANALYTICS_AND_VISUALIZATION_PROMPT.md",
    "04": "04_ITERFACT_NARRATIVE_ARCHITECTURE_AND_PERSUASION_PROMPT.md",
    "05": "05_ITERFACT_INTERACTIVE_TOOLS_AND_CALCULATORS_PROMPT.md",
    "06": "06_ITERFACT_BUSINESS_FRAMEWORK_COMPONENT_LIBRARY_PROMPT.md",
    "07": "07_ITERFACT_DOCUMENT_DESIGN_SYSTEM_PROMPT.md",
    "08": "08_ITERFACT_NATIVE_FIRST_MODERN_WEB_ENFORCEMENT_PROMPT.md",
    "09": "09_ITERFACT_STANDALONE_HTML_EXECUTION_HARDENING_PROMPT.md",
    "10": "10_iterfact_grading_rubric.md",
    "11": "11_ITERFACT_AUTHORED_CONTENT_PRESERVATION_PROMPT.md",
    "12": "12_ITERFACT_TEMPLATE_LANDING_PAGE_AND_SEO_PROMPT.md",
    "13": "13_ITERFACT_TEMPLATE_PERSONALIZATION_AND_SELF_SERVE_PROMPT.md",
    "14": "14_ITERFACT_TRUST_SAFETY_AND_CLAIMS_GUARDRAILS_PROMPT.md",
    "15": "15_ITERFACT_EDITORIAL_CRITIC_AND_REVISION_PROMPT.md",
}

CORE_MODULE_IDS = ("01", "07", "08", "09")
GENERIC_SECTION_TITLES = {
    "overview",
    "summary",
    "introduction",
    "background",
    "analysis",
    "insights",
    "key insights",
    "key takeaways",
    "takeaways",
    "recommendation",
    "recommendations",
    "next steps",
    "conclusion",
    "appendix",
    "details",
}
GENERIC_SECTION_JOBS = {
    "overview",
    "summary",
    "introduction",
    "background",
    "analysis",
    "insights",
    "key takeaways",
    "takeaways",
    "recommendation",
    "recommendations",
    "next steps",
    "conclusion",
    "appendix",
    "details",
    "close",
}
GENERIC_CLAIM_PHRASES = {
    "core thesis",
    "main claim",
    "primary argument",
    "overview",
    "summary",
    "conclusion",
    "takeaway",
    "key takeaway",
}
SLIDE_REQUEST_KEYWORDS = (
    "slide",
    "slides",
    "deck",
    "board",
    "investor",
    "pitch",
    "pitch deck",
    "speaker",
    "presentation",
)
TOUR_REQUEST_KEYWORDS = (
    "tour",
    "walkthrough",
    "guided demo",
    "product demo",
    "feature tour",
    "onboarding tour",
    "step by step",
    "step-by-step",
    "interactive demo",
)
EVENT_REQUEST_KEYWORDS = (
    "event",
    "countdown",
    "invitation",
    "invite",
    "rsvp",
    "conference",
    "webinar",
    "wedding",
    "launch event",
    "gala",
)
CONTRACT_REQUEST_KEYWORDS = (
    "contract",
    "agreement",
    "signature",
    "sign",
    "e-sign",
    "esign",
    "terms",
    "nda",
    "proposal acceptance",
    "acceptance",
    "policy acknowledgment",
)
REPORTISH_KEYWORDS = (
    "report",
    "analysis",
    "brief",
    "dashboard",
    "kpi",
    "memo",
    "research",
    "earnings",
    "competitive",
    "market map",
    "market landscape",
)
COMPARISON_KEYWORDS = (
    "vs",
    "versus",
    "compare",
    "comparison",
    "benchmark",
    "baseline",
    "prior",
    "previous",
    "last month",
    "last quarter",
    "last year",
    "yoy",
    "qoq",
    "mom",
    "growth",
    "delta",
    "target",
)
TIME_SERIES_KEYWORDS = (
    "january",
    "february",
    "march",
    "april",
    "may",
    "june",
    "july",
    "august",
    "september",
    "october",
    "november",
    "december",
    "jan",
    "feb",
    "mar",
    "apr",
    "jun",
    "jul",
    "aug",
    "sep",
    "oct",
    "nov",
    "dec",
    "monthly",
    "quarterly",
    "through",
    "over six months",
    "over 6 months",
    "over twelve months",
    "over 12 months",
)
LANDING_PAGE_KEYWORDS = (
    "landing page",
    "template page",
    "/templates/",
    "template detail",
    "seo page",
)
MINIMAL_PROFILE_KEYWORDS = (
    "faq",
    "policy",
    "terms",
    "knowledge base",
    "handbook",
    "checklist",
    "one pager",
    "one-pager",
    "single page",
    "single-page",
)
IMMERSIVE_PROFILE_KEYWORDS = (
    "immersive",
    "cinematic",
    "parallax",
    "scroll story",
    "brand story",
    "launch",
    "showcase",
    "microsite",
    "interactive experience",
    "reveal",
)
SELF_SERVE_KEYWORDS = (
    "template",
    "personalize",
    "customize",
    "self serve",
    "self-serve",
    "headshot",
    "logo",
    "tagline",
    "brand color",
    "upload your",
)
FAQ_KEYWORDS = ("faq", "policy", "handbook", "disclosure", "questions", "terms")
TIMELINE_KEYWORDS = ("timeline", "roadmap", "rollout", "implementation", "milestones", "launch plan")
CALCULATOR_KEYWORDS = ("calculator", "roi", "estimator", "what-if", "what if", "scenario model", "scenario modeling")
DASHBOARD_KEYWORDS = ("dashboard", "kpi", "kpis", "scorecard", "metrics")
RANKING_KEYWORDS = (
    "ranking",
    "ranking chart",
    "interactive ranking",
    "ranked bar",
    "leaderboard",
    "league table",
    "rank order",
    "market size ranking",
    "market sizing",
    "future arenas",
    "sort by",
    "re-sort",
)
FLOWCHART_KEYWORDS = ("flowchart", "workflow", "process map", "decision tree", "process")
QUIZ_KEYWORDS = ("quiz", "assessment", "readiness", "scorecard", "maturity model", "diagnostic")
PRICING_KEYWORDS = ("pricing", "plans", "plan comparison", "tiers", "packages")
GANTT_KEYWORDS = ("gantt", "roadmap", "implementation plan", "project plan", "rollout")
SOCIAL_PROOF_KEYWORDS = (
    "testimonial",
    "testimonials",
    "social proof",
    "customer story",
    "customer stories",
    "proof quote",
    "rotating proof",
    "global proof",
    "global social proof",
    "customer footprint",
    "global footprint",
    "proof orbit",
    "globe",
)
SOCIAL_PROOF_FORM_KEYWORDS = ("signup", "sign up", "form", "contact", "demo", "trial", "book", "request", "lead")
SOCIAL_PROOF_METRIC_KEYWORDS = ("metric", "metrics", "kpi", "kpis", "stats", "numbers", "adoption", "count", "countries", "customers")
FORM_CAPTURE_REQUEST_KEYWORDS = SOCIAL_PROOF_FORM_KEYWORDS + (
    "feedback",
    "survey",
    "questionnaire",
    "intake",
    "registration",
    "rsvp",
    "subscribe",
    "waitlist",
    "join",
    "apply",
)
BEFORE_AFTER_SURFACE_KEYWORDS = (
    "before and after",
    "before-after",
    "before/after",
    "comparison slider",
    "split slider",
    "image comparison",
    "visual comparison",
    "renovation",
    "transformation",
    "photo editing",
)
REVEAL_CARD_KEYWORDS = (
    "fortune cookie",
    "fortune",
    "flashcard",
    "flash card",
    "flip card",
    "flip-card",
    "reveal card",
    "card reveal",
    "draw a card",
    "card draw",
)
NARRATIVE_KEYWORDS = (
    "report",
    "analysis",
    "brief",
    "memo",
    "research",
    "case study",
    "proposal",
    "cim",
    "confidential information memorandum",
    "investment memo",
    "market map",
    "market landscape",
    "scroll",
    "story",
)
RISKY_CLAIM_WORDS = ("guarantee", "guarantees", "proven", "always", "never", "best in class", "best-in-class")
VISUALIZER_NEGATIVE_CONSTRAINT_LINES = (
    "Visualizer design constraint: no gradients, no drop shadows, no blur or glow effects, and no decorative neon styling.",
    "Visualizer design constraint: no font-weight above 500 anywhere in the artifact. Use only 400 and 500.",
    "Visualizer design constraint: sentence case only for headings, labels, legends, and chart text. Do not use all caps or title case styling.",
    "Visualizer design constraint: use 0.5px borders for standard cards and controls, not heavy 1px+ borders unless a featured callout truly needs it.",
    "Visualizer design constraint: no position: fixed, no 100vh, and no 100vw in generated artifact layouts. Keep embed-safe, auto-height composition.",
    "Visualizer design constraint: avoid tabs, carousels, display:none content, and nested scrolling. Keep content directly visible and progressively disclosed only when necessary.",
)
VISUALIZER_CHART_RAMP_LINES = (
    "Visualizer chart ramps: purple 50 #EEEDFE, 100 #CECBF6, 200 #AFA9EC, 400 #7F77DD, 600 #534AB7, 800 #3C3489, 900 #26215C.",
    "Visualizer chart ramps: teal 50 #E1F5EE, 100 #9FE1CB, 200 #5DCAA5, 400 #1D9E75, 600 #0F6E56, 800 #085041, 900 #04342C.",
    "Visualizer chart ramps: coral 50 #FAECE7, 100 #F5C4B3, 200 #F0997B, 400 #D85A30, 600 #993C1D, 800 #712B13, 900 #4A1B0C.",
    "Visualizer chart ramps: pink 50 #FBEAF0, 100 #F4C0D1, 200 #ED93B1, 400 #D4537E, 600 #993556, 800 #72243E, 900 #4B1528.",
    "Visualizer chart ramps: gray 50 #F1EFE8, 100 #D3D1C7, 200 #B4B2A9, 400 #888780, 600 #5F5E5A, 800 #444441, 900 #2C2C2A.",
    "Visualizer chart ramps: blue 50 #E6F1FB, 100 #B5D4F4, 200 #85B7EB, 400 #378ADD, 600 #185FA5, 800 #0C447C, 900 #042C53.",
    "Visualizer chart ramps: green 50 #EAF3DE, 100 #C0DD97, 200 #97C459, 400 #639922, 600 #3B6D11, 800 #27500A, 900 #173404.",
    "Visualizer chart ramps: amber 50 #FAEEDA, 100 #FAC775, 200 #EF9F27, 400 #BA7517, 600 #854F0B, 800 #633806, 900 #412402.",
    "Visualizer chart ramps: red 50 #FCEBEB, 100 #F7C1C1, 200 #F09595, 400 #E24B4A, 600 #A32D2D, 800 #791F1F, 900 #501313.",
)


def _prompt_contains_any(text: str | None, keywords: tuple[str, ...]) -> bool:
    lower = (text or "").lower()
    for keyword in keywords:
        normalized = (keyword or "").strip().lower()
        if not normalized:
            continue
        pattern = rf"(?<![a-z0-9]){re.escape(normalized)}(?![a-z0-9])"
        if re.search(pattern, lower):
            return True
    return False


def _is_dashboard_request(prompt: str, template_hint: str | None = None, structured_data: dict[str, Any] | None = None) -> bool:
    if _prompt_contains_any(prompt, DASHBOARD_KEYWORDS):
        return True
    if template_hint in {"financial_briefing"}:
        return True
    structured_keys = _top_level_structured_keys(structured_data)
    return bool({"metrics", "kpis", "dashboard", "charts"} & structured_keys)


def _is_ranking_request(prompt: str, template_hint: str | None = None, structured_data: dict[str, Any] | None = None) -> bool:
    if _prompt_contains_any(prompt, RANKING_KEYWORDS):
        return True
    structured_keys = _top_level_structured_keys(structured_data)
    return bool(
        "ranking_chart" in structured_keys
        or {"items", "sort_options"} <= structured_keys
        or template_hint in {"ranking_chart", "interactive_ranking", "ranking", "leaderboard"}
    )


def _is_calculator_request(prompt: str, template_hint: str | None = None, structured_data: dict[str, Any] | None = None) -> bool:
    if _prompt_contains_any(prompt, CALCULATOR_KEYWORDS):
        return True
    if template_hint in {"client_proposal"} and _prompt_contains_any(prompt, ("roi", "pricing", "calculator", "scenario")):
        return True
    structured_keys = _top_level_structured_keys(structured_data)
    return bool({"inputs", "variables", "outputs", "scenarios", "calculator"} & structured_keys)


def _is_social_proof_request(prompt: str, template_hint: str | None = None, structured_data: dict[str, Any] | None = None) -> bool:
    lower_prompt = (prompt or "").lower()
    lower_hint = (template_hint or "").lower()
    if _prompt_contains_any(prompt, SOCIAL_PROOF_KEYWORDS):
        if any(token in lower_prompt for token in ("globe", "global", "footprint", "orbit", "world")):
            return True
        if "social proof" in lower_prompt or "customer stories" in lower_prompt or "rotating testimonials" in lower_prompt:
            return True
    structured_keys = _top_level_structured_keys(structured_data)
    return (
        {"testimonials", "locations"} <= structured_keys
        or lower_hint in {"globe_testimonials", "global_proof", "social_proof"}
    )


def _prompt_requests_form_capture(prompt: str, template_hint: str | None = None) -> bool:
    return bool(
        _prompt_contains_any(prompt, FORM_CAPTURE_REQUEST_KEYWORDS)
        or (template_hint or "").lower() in {"client_intake", "registration", "lead_capture", "assessment"}
    )


def _prompt_supports_metric_surface(
    prompt: str,
    template_hint: str | None = None,
    structured_data: dict[str, Any] | None = None,
) -> bool:
    structured_keys = _top_level_structured_keys(structured_data)
    return bool(
        _is_dashboard_request(prompt, template_hint, structured_data)
        or _prompt_contains_any(prompt, SOCIAL_PROOF_METRIC_KEYWORDS)
        or _prompt_contains_any(prompt, ("impact", "traction", "usage", "views", "stats", "numbers", "counter", "count-up"))
        or bool({"metrics", "kpis", "stats", "numbers", "counters"} & structured_keys)
    )


def _prompt_supports_before_after_surface(prompt: str, template_hint: str | None = None) -> bool:
    lowered_hint = (template_hint or "").lower()
    return bool(
        _prompt_contains_any(prompt, BEFORE_AFTER_SURFACE_KEYWORDS)
        or lowered_hint in {"before_after", "before-after", "visual_compare", "image_comparison"}
    )


def _is_reveal_card_request(prompt: str, template_hint: str | None = None) -> bool:
    lowered = (prompt or "").lower()
    return bool(
        _prompt_contains_any(prompt, REVEAL_CARD_KEYWORDS)
        or ("crack open" in lowered and ("fortune" in lowered or "cookie" in lowered))
        or ("cycle through" in lowered and ("fortune" in lowered or "card" in lowered))
        or ((template_hint or "").lower() in {"flashcard", "fortune_cookie", "card_reveal"})
    )


def _is_narrative_request(prompt: str, template_hint: str | None = None) -> bool:
    return bool(
        _prompt_contains_any(prompt, NARRATIVE_KEYWORDS)
        or template_hint in {"research_report", "financial_briefing"}
    )


def _is_high_stakes_request(prompt: str, template_hint: str | None = None, structured_data: dict[str, Any] | None = None) -> bool:
    lowered = (prompt or "").lower()
    structured_keys = _top_level_structured_keys(structured_data)
    return any(
        token in lowered
        for token in (
            "financial",
            "finance",
            "earnings",
            "forecast",
            "valuation",
            "investment",
            "board",
            "proposal",
            "client-facing",
            "client facing",
            "pricing",
            "roi",
            "calculator",
            "legal",
            "compliance",
            "policy",
            "contract",
            "disclosure",
            "medical",
            "healthcare",
        )
    ) or template_hint in {"financial_briefing", "research_report", "client_proposal"} or bool(
        {"financials", "kpis", "inputs", "outputs", "metrics", "benchmarks"} & structured_keys
    )


def _infer_composition_profile(
    prompt: str,
    template_hint: str | None = None,
    structured_data: dict[str, Any] | None = None,
) -> str:
    structured_keys = _top_level_structured_keys(structured_data)
    if (
        _is_dashboard_request(prompt, template_hint, structured_data)
        or _is_calculator_request(prompt, template_hint, structured_data)
        or _prompt_contains_any(prompt, FLOWCHART_KEYWORDS)
        or _prompt_contains_any(prompt, PRICING_KEYWORDS)
        or _prompt_contains_any(prompt, QUIZ_KEYWORDS)
        or _prompt_contains_any(prompt, GANTT_KEYWORDS)
        or bool({"financials", "metrics", "kpis", "charts", "inputs", "outputs", "benchmarks", "comparison"} & structured_keys)
    ):
        return "data-forward"
    if _prompt_contains_any(prompt, MINIMAL_PROFILE_KEYWORDS):
        return "minimal"
    if _prompt_contains_any(prompt, IMMERSIVE_PROFILE_KEYWORDS):
        return "immersive"
    if _is_narrative_request(prompt, template_hint) or _prompt_contains_any(prompt, LANDING_PAGE_KEYWORDS):
        return "editorial"
    return "editorial"


def _dedupe_preserve_order(items: list[str]) -> list[str]:
    seen: set[str] = set()
    ordered: list[str] = []
    for item in items:
        if item in seen:
            continue
        seen.add(item)
        ordered.append(item)
    return ordered


def _normalize_title(title: str) -> str:
    normalized = re.sub(r"[^a-z0-9]+", " ", (title or "").strip().lower())
    return re.sub(r"\s+", " ", normalized).strip()


def _collect_strings(node: Any) -> list[str]:
    if isinstance(node, str):
        return [node]
    if isinstance(node, dict):
        values: list[str] = []
        for value in node.values():
            values.extend(_collect_strings(value))
        return values
    if isinstance(node, list):
        values = []
        for value in node:
            values.extend(_collect_strings(value))
        return values
    return []


def _collect_component_engines(plan: dict[str, Any]) -> set[str]:
    engines: set[str] = set()
    wrapper_engine = str(plan.get("wrapper", {}).get("engine") or "").strip()
    if wrapper_engine:
        engines.add(wrapper_engine)
    for section in plan.get("sections", []) or []:
        section_engine = str(section.get("engine") or "").strip()
        if section_engine:
            engines.add(section_engine)
        for component in section.get("components", []) or []:
            engine = str(component.get("engine") or "").strip()
            if engine:
                engines.add(engine)
    return engines


def _extract_narrative_spine(plan: dict[str, Any]) -> dict[str, Any]:
    meta = plan.get("meta") if isinstance(plan.get("meta"), dict) else {}
    spine = meta.get("narrative_spine")
    if not isinstance(spine, dict):
        spine = plan.get("narrative_spine")
    return spine if isinstance(spine, dict) else {}


def _normalize_string_list(values: Any) -> list[str]:
    if not isinstance(values, list):
        return []
    normalized: list[str] = []
    for value in values:
        text = compact = re.sub(r"\s+", " ", str(value or "")).strip()
        if text:
            normalized.append(compact)
    return normalized


def _normalize_section_job_labels(spine: dict[str, Any]) -> list[str]:
    section_jobs = spine.get("section_jobs")
    normalized: list[str] = []
    if not isinstance(section_jobs, list):
        return normalized
    for item in section_jobs:
        if isinstance(item, dict):
            label = re.sub(r"\s+", " ", str(item.get("job") or item.get("role") or item.get("title") or "")).strip()
        else:
            label = re.sub(r"\s+", " ", str(item or "")).strip()
        if label:
            normalized.append(label)
    return normalized


def _normalize_claim_map(spine: dict[str, Any]) -> list[dict[str, Any]]:
    raw = spine.get("claim_map")
    if not isinstance(raw, list):
        return []
    normalized: list[dict[str, Any]] = []
    for item in raw:
        if isinstance(item, dict):
            claim = re.sub(r"\s+", " ", str(item.get("claim") or item.get("title") or item.get("statement") or "")).strip()
            raw_support_sections = item.get("support_sections")
        else:
            claim = re.sub(r"\s+", " ", str(item or "")).strip()
            raw_support_sections = None
        if not claim:
            continue
        support_sections: list[int] = []
        if isinstance(raw_support_sections, list):
            for raw_index in raw_support_sections:
                try:
                    section_index = int(raw_index)
                except (TypeError, ValueError):
                    continue
                if section_index > 0 and section_index not in support_sections:
                    support_sections.append(section_index)
        normalized.append(
            {
                "claim": claim,
                "support_sections": support_sections,
            }
        )
    return normalized


def _plan_contains_delta_like_values(plan: dict[str, Any]) -> bool:
    def walk(node: Any) -> bool:
        if isinstance(node, dict):
            for key, value in node.items():
                lowered_key = str(key).lower()
                if lowered_key in {"delta", "change", "direction"}:
                    return True
                if walk(value):
                    return True
            return False
        if isinstance(node, list):
            return any(walk(value) for value in node)
        if isinstance(node, str):
            lowered = node.lower()
            return any(token in lowered for token in (" vs ", "versus", "yoy", "qoq", "mom", "delta", "change"))
        return False

    return walk(plan)


def _top_level_structured_keys(structured_data: dict[str, Any] | None) -> set[str]:
    if not isinstance(structured_data, dict):
        return set()
    return {str(key).strip().lower() for key in structured_data.keys()}


def prompt_stack_bundle_version() -> str:
    digest = hashlib.sha1()
    digest.update(b"iterfact-runtime-bundle-v1")
    for module_id in sorted(MODULE_FILES):
        prompt_path = PROMPTS_DIR / MODULE_FILES[module_id]
        if prompt_path.exists():
            digest.update(module_id.encode("ascii"))
            digest.update(prompt_path.read_bytes())
    return f"2026-03-21-runtime-{digest.hexdigest()[:10]}"


PROMPT_STACK_BUNDLE_VERSION = prompt_stack_bundle_version()


def resolve_active_prompt_modules(
    *,
    prompt: str,
    template_hint: str | None = None,
    uploaded_content: str | None = None,
    structured_data: dict[str, Any] | None = None,
    generation_mode: str | None = None,
    public_artifact: bool = True,
) -> list[str]:
    modules = list(CORE_MODULE_IDS)
    lower_prompt = (prompt or "").lower()
    lower_hint = (template_hint or "").lower()

    if _is_ranking_request(prompt, template_hint, structured_data):
        modules.extend(["02", "03"])
    elif _is_dashboard_request(prompt, template_hint, structured_data):
        modules.extend(["02", "03"])
    elif _is_calculator_request(prompt, template_hint, structured_data):
        modules.append("05")
    elif _is_social_proof_request(prompt, template_hint, structured_data):
        modules.extend(["04", "12"])
    elif _is_narrative_request(prompt, template_hint):
        modules.extend(["02", "04", "06"])
    else:
        modules.extend(["02", "04"])

    if structured_data or _is_ranking_request(prompt, template_hint, structured_data) or _is_dashboard_request(prompt, template_hint, structured_data) or _is_calculator_request(prompt, template_hint, structured_data) or _is_narrative_request(prompt, template_hint):
        modules.append("10")

    if uploaded_content and uploaded_content.strip():
        modules.append("11")

    if _prompt_contains_any(lower_prompt, LANDING_PAGE_KEYWORDS) or "landing" in lower_hint:
        modules.append("12")

    if (
        generation_mode == "template_instance_render"
        or _prompt_contains_any(lower_prompt, SELF_SERVE_KEYWORDS)
        or any(token in lower_hint for token in ("template", "personal", "brand"))
    ):
        modules.append("13")

    if public_artifact:
        modules.append("14")

    if _is_high_stakes_request(prompt, template_hint, structured_data) or public_artifact or uploaded_content:
        modules.append("15")

    return _dedupe_preserve_order(modules)


def build_runtime_execution_lines(
    *,
    active_modules: list[str],
    prompt: str,
    structured_data: dict[str, Any] | None = None,
) -> list[str]:
    lines: list[str] = []

    if "09" in active_modules:
        lines.extend(
            [
                "Runtime hardening extract: keep all quantitative inputs, defaults, chart series, and display values in explicit data/config objects rather than scattering numbers through the code.",
                "For calculations, use null-safe math utilities such as safeDivide(numerator, denominator, fallback), safeSum(array), and safeAverage(array). Never allow NaN, Infinity, undefined, or null to reach visible output.",
                "For missing or invalid results, render an em dash instead of a broken number.",
                "Before finishing a calculation-heavy plan, run a pre-flight mental checklist: exact values preserved, units visible, formulas bounded, zero-divides handled, and no fabricated comparison basis.",
            ]
        )

    if "05" in active_modules or _is_calculator_request(prompt, None, structured_data):
        lines.extend(
            [
                "Calculator/modeler extract: define explicit inputs, explicit output expressions, and output formatting rules. Do not rely on a generic hardcoded ROI example when the request supplies its own math.",
                "If any expression divides, call safeDivide instead of raw division.",
            ]
        )

    if "03" in active_modules or _is_dashboard_request(prompt, None, structured_data):
        lines.extend(
            [
                "Dashboard extract: every KPI and chart value must come from the same declared data source, and exact prompt numbers should survive untouched into labels, cards, and series.",
                "Do not fabricate growth rates, deltas, or targets unless the prompt or structured data provides a valid baseline.",
            ]
        )

    return _dedupe_preserve_order(lines)


def build_runtime_planner_lines(
    *,
    active_modules: list[str],
    prompt: str,
    template_hint: str | None = None,
    structured_data: dict[str, Any] | None = None,
    uploaded_content_present: bool = False,
) -> list[str]:
    lines: list[str] = []
    composition_profile = _infer_composition_profile(prompt, template_hint, structured_data)

    if "01" in active_modules:
        lines.extend(
            [
                "Treat facts, dates, metrics, and quotes as bounded inputs. If the request does not support a number, baseline, or quote, omit it rather than invent it.",
                "Preserve gaps and uncertainties honestly. Missing evidence is a reason to narrow the claim, not to pad with more confident copy.",
            ]
        )
    if "02" in active_modules:
        lines.extend(
            [
                "Normalize units, time periods, and currencies consistently across sections. Do not compare mismatched periods or mixed units without saying so.",
                "When numeric inputs appear in the prompt or structured data, keep them stable across the whole plan and do not mutate them in later sections.",
            ]
        )
    if "03" in active_modules:
        lines.extend(
            [
                "Match the analytical surface to the job: dashboard for KPIs, comparison matrix for options, timeline for chronology, scenario model only when inputs materially change outputs.",
                "Do not fabricate deltas, growth rates, or benchmarks when the request does not provide a baseline.",
            ]
        )
    if "04" in active_modules:
        lines.extend(
            [
                "Make each section earn its place with one distinct job. Avoid interchangeable filler chapters such as generic overview, why it matters, and key takeaways.",
                "A persuasive artifact should acknowledge limits, objections, or decision boundaries when they matter to trust.",
                "Return a meta.narrative_spine object with these keys: core_thesis, audience, desired_action, throughlines, must_keep_facts, must_not_claim, key_entities, claim_map, closing_obligation, and section_jobs.",
                "narrative_spine.section_jobs must cover the full artifact in order. Each section job should be distinct, concrete, and tied to what that section does for the argument.",
                "narrative_spine.claim_map should name the core claims the artifact is making and which section indices support each claim.",
            ]
        )
    if "05" in active_modules:
        lines.extend(
            [
                "Only choose interactive tools when user input materially changes the result. Do not bolt on sliders, toggles, or tabs as decoration.",
            ]
        )
    if "06" in active_modules:
        lines.extend(
            [
                "Use at most one dominant business framework in a section unless the request explicitly demands side-by-side frameworks.",
            ]
        )
    if "07" in active_modules:
        lines.extend(
            [
                "Favor direct labels, action-oriented titles, and visually differentiated section rhythm over generic hero-plus-card repetition.",
                *VISUALIZER_NEGATIVE_CONSTRAINT_LINES[:4],
            ]
        )
    if "08" in active_modules:
        lines.extend(
            [
                "Prefer native browser primitives and container-aware layouts over custom interaction scaffolding when the platform already provides the behavior.",
                *VISUALIZER_NEGATIVE_CONSTRAINT_LINES[4:],
            ]
        )
    if "09" in active_modules:
        lines.extend(
            [
                "Keep the plan renderable and robust. If a feature is likely to break, simplify it instead of speculating.",
                "Never plan visible placeholders, undefined values, or code-like outputs as if they were acceptable ship states.",
                "For charts, disable default Chart.js legends and build explicit HTML legends. Set chart height on the wrapper div, not the canvas.",
                "For charts with 12 or fewer categories, set autoSkip to false so labels do not silently disappear.",
                "Round every displayed number with Intl.NumberFormat, Math.round, or toFixed before it reaches the screen.",
            ]
        )
    if "11" in active_modules and uploaded_content_present:
        lines.extend(
            [
                "Preserve the uploaded source's section logic, must-keep specifics, and meaningful tensions. Transform utility, not truth.",
                "Do not silently compress a source-backed outline into generic summary chapters unless the request clearly asks for compression.",
            ]
        )
    if "12" in active_modules:
        lines.extend(
            [
                "A template landing page must help a direct visitor understand fit, required input, output type, and why this template differs from neighboring templates.",
                "Search-friendly pages still need original page-level value. Do not rely on keyword-swapped filler or generic FAQ sludge.",
            ]
        )
    if "13" in active_modules:
        lines.extend(
            [
                "If the artifact is becoming a reusable template, keep data, branding, inputs, and layout logic separable so the output can be personalized later without reauthoring the whole artifact.",
                "Ask for the minimum user input needed, and make the layout resilient to messy real-world inputs such as long taglines, weak logos, or missing headshots.",
            ]
        )
    if "14" in active_modules:
        lines.extend(
            [
                "Review both express and implied claims. Do not let headings, chart titles, or annotations imply proof the request does not support.",
                "Avoid unsupported objective claims, fabricated proof, or overly absolute wording such as guaranteed, proven, always, or never unless the evidence genuinely supports it.",
            ]
        )
    if "15" in active_modules:
        lines.extend(
            [
                "Write the plan as if it will be cold-read by a skeptical editor. Replace generic fluency with specifics, mechanisms, and artifact-specific titles.",
                "If a section could appear in 100 other artifacts unchanged, it is too generic and should be sharpened or removed.",
            ]
        )

    if structured_data:
        lines.append(
            "Structured data is present. Use it as the source of truth for values, labels, and series. Do not improve, extrapolate, or round into a different story."
        )
    lines.append(
        "Narrative coherence requirement: the opening promise, section jobs, evidence sequence, and close must all reinforce the same core thesis instead of feeling like independently generated blocks."
    )
    lines.append(
        f"Composition profile: {composition_profile}. Match section count, density, wrapper choice, and pacing to this profile instead of defaulting to a generic marketing shell."
    )
    if composition_profile == "minimal":
        lines.extend(
            [
                "Minimal profile: keep the artifact direct and utility-first. Prefer 1-3 sections, one dominant engine, and the least intrusive wrapper possible.",
                "Minimal profile: avoid slide-deck, parallax, guided-tour, or decorative motion unless the user explicitly asked for that treatment.",
            ]
        )
    elif composition_profile == "editorial":
        lines.extend(
            [
                "Editorial profile: use 3-5 sections with distinct section jobs, generous rhythm, and a clear narrative arc from opening context to proof to close.",
                "Editorial profile: prefer guided document or restrained scrollytelling compositions over slide-deck defaults unless the user explicitly asked for slides.",
            ]
        )
    elif composition_profile == "data-forward":
        lines.extend(
            [
                "Data-forward profile: lead with the analytical surface. KPI cards, charts, model outputs, comparison tables, or scenario controls should appear before explanatory prose.",
                "Data-forward profile: use wrappers only to orient the reader. Do not let cinematic chrome or scrollytelling motion dominate the primary data surface.",
            ]
        )
    elif composition_profile == "immersive":
        lines.extend(
            [
                "Immersive profile: higher visual ambition is allowed, but only when the prompt clearly signals launch, showcase, reveal, or brand-story pacing.",
                "Immersive profile: motion and wrapper choice still need to serve comprehension. Do not sacrifice clarity, usable data, or CTA legibility for atmosphere.",
            ]
        )
    lines.extend(
        build_runtime_execution_lines(
            active_modules=active_modules,
            prompt=prompt,
            structured_data=structured_data,
        )
    )
    if "07" in active_modules or "08" in active_modules or "09" in active_modules:
        lines.extend(VISUALIZER_CHART_RAMP_LINES)

    if _prompt_contains_any(prompt, DASHBOARD_KEYWORDS):
        lines.append("This request explicitly asks for a dashboard or KPI surface. Ensure the plan includes a real KPI/dashboard component instead of a mostly prose narrative.")
        lines.append("Explicit routing rule: dashboard or KPI requests should use engine 03 as the dominant data surface unless the user explicitly asks for a different engine.")
    if _is_ranking_request(prompt, template_hint, structured_data):
        lines.append("This request wants a ranked interactive data view, not a generic dashboard or static comparison table.")
        lines.append("Explicit routing rule: ranking charts, league tables, market-size rankings, and re-sortable horizontal bar requests should use engine 44 as the dominant data surface.")
    if _prompt_contains_any(prompt, SLIDE_REQUEST_KEYWORDS):
        lines.append("Explicit routing rule: slide, deck, investor presentation, and keynote-style requests should use engine 02 as the wrapper unless the user explicitly asks for a different presentation surface.")
    if _prompt_contains_any(prompt, CALCULATOR_KEYWORDS):
        lines.append("This request explicitly asks for a calculator or scenario model. Include an actual calculator/modeling component or explain why not.")
        lines.append("Explicit routing rule: ROI, calculator, or estimator requests should use engine 08; scenario-modeling or forecasting requests should use engine 17.")
    if _prompt_contains_any(prompt, FAQ_KEYWORDS):
        lines.append("This request is disclosure or FAQ heavy. Prefer a real disclosure pattern over long undifferentiated prose.")
    if _prompt_contains_any(prompt, TIMELINE_KEYWORDS):
        lines.append("This request is timeline-oriented. Include a real milestone or timeline surface rather than burying chronology inside generic prose.")
        lines.append("Explicit routing rule: timeline requests should use engine 05; gantt or roadmap requests should use engine 31.")
    if _prompt_contains_any(prompt, FLOWCHART_KEYWORDS):
        lines.append("Explicit routing rule: process, workflow, and flowchart requests should use engine 06 rather than prose explanation alone.")
    if _prompt_contains_any(prompt, QUIZ_KEYWORDS):
        lines.append("Explicit routing rule: quiz and assessment requests should use engine 09 when the user expects scored interaction.")
    if _prompt_contains_any(prompt, PRICING_KEYWORDS):
        lines.append("Explicit routing rule: pricing and plans requests should use engine 11 for the core comparison surface.")
    if _is_reveal_card_request(prompt, template_hint):
        lines.append("This request wants a playful reveal-card experience, not a quiz, before/after comparison, counter wall, or lead form.")
        lines.append("Explicit routing rule: fortune-cookie, flashcard, reveal-card, and draw-a-card prompts should use engine 13 as the dominant interaction surface, with a lightweight close.")
    if _prompt_contains_any(prompt, TOUR_REQUEST_KEYWORDS):
        lines.append("Explicit routing rule: walkthrough, onboarding tour, and guided product demo requests should use engine 21 when the user wants a step-by-step interface tour.")
        lines.append("For engine 21, keep the walkthrough direct and product-led. Do not turn it into a theatrical overlay or a generic landing page.")
    if _prompt_contains_any(prompt, EVENT_REQUEST_KEYWORDS):
        lines.append("Explicit routing rule: event, invitation, RSVP, and countdown requests should use engine 24 only when the user actually wants an event-style page.")
    if _prompt_contains_any(prompt, CONTRACT_REQUEST_KEYWORDS):
        lines.append("Explicit routing rule: contract, signature, policy acknowledgment, and acceptance requests should use engine 25 only when the artifact truly needs acceptance or signing behavior.")
    if _is_social_proof_request(prompt, template_hint, structured_data):
        lines.append("This request is social-proof heavy and specifically wants global or orbit-style proof. Prefer engine 39 over a generic testimonial carousel.")
        lines.append("Explicit routing rule: global proof, testimonial globe, customer footprint, and rotating proof requests should use engine 39 as the dominant proof surface.")
        lines.append("Keep surrounding sections lean and evidence-led. Do not invent platform adoption metrics, country counts, or lead forms unless the prompt explicitly asks for them.")
        lines.append("For globe-proof requests, prefer a content-led hero and a light closing CTA over counter walls, signup widgets, or unrelated extra engines.")
        lines.append("If the user says CTA or closing CTA, interpret that as a lightweight call-to-action section by default, not a data-collection form.")
    if template_hint:
        lines.append(f'Use the selected template direction "{template_hint}" as a real compositional constraint, not a loose suggestion.')

    return _dedupe_preserve_order(lines)


def build_runtime_critic_lines(
    *,
    active_modules: list[str],
    prompt: str,
    structured_data: dict[str, Any] | None = None,
    uploaded_content_present: bool = False,
) -> list[str]:
    composition_profile = _infer_composition_profile(prompt, None, structured_data)
    lines: list[str] = [
        "Perform a cold-read review of the rendered artifact, not just the plan JSON.",
        "Diagnose first, then recommend the smallest set of section-level changes that would materially improve the artifact.",
        "Preserve the strongest sections and only rewrite weak, broken, generic, or unsupported sections.",
        "Use the quality rubric dimensions as checks: clarity of purpose, data integrity and truthfulness, visual design, interactivity, narrative density, technical polish, and AI-signal risk.",
        "Technical failures outrank polish. If a section renders broken numbers, missing charts, empty states, or non-working interactions, fix that before tightening tone.",
    ]

    if "01" in active_modules:
        lines.append("Treat prompt facts, dates, metrics, and quotes as bounded truth. If the output drifts from them, call that out directly.")
    if "02" in active_modules:
        lines.append("Check for unit drift, mixed periods, mutated values, and unsupported comparisons across sections.")
    if "03" in active_modules:
        lines.append("For dashboards, verify that the rendered artifact actually behaves like a KPI/dashboard surface rather than decorative narrative.")
    if "04" in active_modules:
        lines.append("Flag sections whose only job is polished filler. Replace them with sharper evidence, mechanism, or decision value.")
    if "05" in active_modules:
        lines.append("For calculator and modeler artifacts, verify that inputs materially change outputs and that the visible outputs respect the requested formulas and ranges.")
    if "06" in active_modules:
        lines.append("Do not let multiple frameworks blur together. If the artifact tries to do everything at once, identify the section where the job is muddled.")
    if "07" in active_modules:
        lines.append("Flag templated visual sameness, repeated section rhythm, and summaries that simply restate nearby charts.")
    if "08" in active_modules:
        lines.append("Prefer native, robust, embed-safe patterns over fragile interaction scaffolding when a section needs revision.")
    if "09" in active_modules:
        lines.append("Reject visible NaN, undefined, null, long raw floats, placeholder copy, and brittle calculator logic. These are ship blockers.")
        lines.append("Flag gradients, shadows, heavy borders, uppercase styling, font-weight above 500, position: fixed, and 100vh/100vw as visual system violations.")
    if "10" in active_modules:
        lines.append("Treat low truthfulness, broken interactivity, or obvious AI-signal as reasons to withhold ship approval even if the artifact looks polished.")
    if "11" in active_modules and uploaded_content_present:
        lines.append("When uploaded source material exists, protect section intent, must-keep specifics, and outline fidelity. Do not flatten source-backed sections into generic summaries.")
    if "12" in active_modules:
        lines.append("For template landing pages, check whether the page clearly explains best fit, required input, output type, and why this template differs from nearby options.")
    if "13" in active_modules:
        lines.append("For self-serve template artifacts, check that inputs are minimal, comprehensible, and resilient to messy real-world user content.")
    if "14" in active_modules:
        lines.append("Explicitly flag unsupported or overly absolute claims, including implied proof in headings, annotations, and CTA language.")
    if "15" in active_modules:
        lines.append("Use the editorial critic order: fix purpose and opening first, then evidence and claims, then section architecture, then information density, then polish.")
    lines.append("Check whether the artifact still honors one coherent narrative spine from opening promise through close. If the argument feels stitched together section by section, call that out.")
    lines.append("Explicitly flag claim drift and entity drift: if named companies, products, metrics, or commitments mutate across sections, or if the claim map is not actually supported by the rendered artifact, call that out.")

    if structured_data:
        lines.append("Structured data is present. Compare the rendered numbers, labels, and outputs against that data instead of accepting persuasive paraphrase.")
    lines.append(f"Check whether the rendered pacing and composition actually match the intended {composition_profile} profile.")
    if composition_profile == "minimal":
        lines.append("A minimal-profile artifact fails if it feels overbuilt, wrapper-heavy, or visually louder than the job requires.")
    elif composition_profile == "editorial":
        lines.append("An editorial-profile artifact fails if every section feels interchangeable or if it slips into generic slide cadence without being asked.")
    elif composition_profile == "data-forward":
        lines.append("A data-forward artifact fails if prose dominates the first screen or if wrapper treatment eclipses the analytical surface.")
    elif composition_profile == "immersive":
        lines.append("An immersive artifact fails if motion feels decorative, repetitive, or more memorable than the actual information hierarchy.")

    if _prompt_contains_any(prompt, DASHBOARD_KEYWORDS):
        lines.append("A dashboard request fails if the rendered artifact lacks a real KPI or chart surface, or invents deltas/baselines not provided.")
        lines.extend(VISUALIZER_CHART_RAMP_LINES[:3])
    if _prompt_contains_any(prompt, SLIDE_REQUEST_KEYWORDS):
        lines.append("A slide or deck request fails if the artifact reads like a report, scroll story, or generic page instead of a real presentation surface.")
    if _prompt_contains_any(prompt, CALCULATOR_KEYWORDS):
        lines.append("A calculator request fails if visible outputs do not change with inputs, if formulas appear hardcoded to the wrong example, or if broken numbers reach the page.")
    if _prompt_contains_any(prompt, FLOWCHART_KEYWORDS):
        lines.append("A flowchart request fails if the rendered output explains the process in prose without a real flow surface.")
    if _prompt_contains_any(prompt, PRICING_KEYWORDS):
        lines.append("A pricing request fails if plan comparisons are buried in generic text instead of a legible comparison surface.")
    if _prompt_contains_any(prompt, TOUR_REQUEST_KEYWORDS):
        lines.append("A walkthrough or demo request fails if the artifact lacks a real step-by-step tour surface or if the product preview is treated as decorative background instead of the main teaching surface.")
    if _prompt_contains_any(prompt, EVENT_REQUEST_KEYWORDS):
        lines.append("An event request fails if it becomes a generic marketing page instead of a clear invitation, schedule, or countdown surface.")
    if _prompt_contains_any(prompt, CONTRACT_REQUEST_KEYWORDS):
        lines.append("A contract or acceptance request fails if it lacks the actual acknowledgment, acceptance, or signature surface the prompt implies.")
    if _is_social_proof_request(prompt, None, structured_data):
        lines.append("A global social-proof request fails if the artifact falls back to a generic quote carousel or prose-only proof section instead of a globe-driven or footprint-aware proof surface.")
        lines.append("A global social-proof request also fails if it invents adoption metrics, country totals, or lead forms that the prompt never asked for.")

    return _dedupe_preserve_order(lines)


def audit_plan_against_prompt_stack(
    *,
    plan: dict[str, Any],
    prompt: str,
    template_hint: str | None = None,
    structured_data: dict[str, Any] | None = None,
    uploaded_heading_count: int = 0,
    active_modules: list[str] | None = None,
) -> dict[str, Any]:
    modules = active_modules or resolve_active_prompt_modules(
        prompt=prompt,
        template_hint=template_hint,
        uploaded_content=None,
        structured_data=structured_data,
    )
    lower_prompt = (prompt or "").lower()
    composition_profile = _infer_composition_profile(prompt, template_hint, structured_data)
    wrapper_engine = str(plan.get("wrapper", {}).get("engine") or "").strip()
    engines = _collect_component_engines(plan)
    section_titles = [str(section.get("title") or "").strip() for section in (plan.get("sections") or []) if str(section.get("title") or "").strip()]
    normalized_titles = [_normalize_title(title) for title in section_titles]
    all_plan_text = " ".join(_collect_strings(plan)).lower()
    structured_keys = _top_level_structured_keys(structured_data)
    spine = _extract_narrative_spine(plan)
    spine_source = re.sub(r"\s+", " ", str(spine.get("source") or "")).strip().lower()
    core_thesis = re.sub(r"\s+", " ", str(spine.get("core_thesis") or "")).strip()
    throughlines = _normalize_string_list(spine.get("throughlines"))
    must_keep_facts = _normalize_string_list(spine.get("must_keep_facts"))
    key_entities = _normalize_string_list(spine.get("key_entities"))
    claim_map = _normalize_claim_map(spine)
    closing_obligation = re.sub(r"\s+", " ", str(spine.get("closing_obligation") or "")).strip()
    section_job_labels = _normalize_section_job_labels(spine)

    findings: list[dict[str, Any]] = []
    feedback_lines: list[str] = []
    score_penalty = 0
    critical = False

    def add_finding(
        *,
        code: str,
        severity: str,
        penalty: int,
        message: str,
        feedback: str,
        critical_issue: bool = False,
    ) -> None:
        nonlocal score_penalty, critical
        findings.append(
            {
                "type": code,
                "severity": severity,
                "message": message,
                "penalty": penalty,
            }
        )
        feedback_lines.append(feedback)
        score_penalty += penalty
        critical = critical or critical_issue

    generic_title_count = sum(1 for title in normalized_titles if title in GENERIC_SECTION_TITLES)
    duplicate_titles = sorted({title for title in normalized_titles if title and normalized_titles.count(title) > 1})
    if generic_title_count >= 2 or duplicate_titles:
        add_finding(
            code="generic_section_pattern",
            severity="medium",
            penalty=6,
            message="The plan relies on generic or repeated section titles, which often leads to obviously AI-smoothed artifacts.",
            feedback="Replace generic section titles with artifact-specific claim, decision, or evidence titles. Remove repeated overview/summary/next-step filler sections.",
        )

    if composition_profile in {"editorial", "data-forward", "immersive"} and not core_thesis:
        add_finding(
            code="narrative_spine_missing",
            severity="high",
            penalty=8,
            message="The plan does not define a core narrative spine, so sections are more likely to feel stitched together instead of authored as one artifact.",
            feedback="Add meta.narrative_spine with a clear core_thesis, throughlines, closing_obligation, and section_jobs before shipping the plan.",
            critical_issue=True,
        )
    elif composition_profile in {"editorial", "data-forward", "immersive"} and spine_source == "derived":
        add_finding(
            code="narrative_spine_implicit",
            severity="medium",
            penalty=5,
            message="The plan is relying on a derived fallback narrative spine instead of an explicitly authored one from the planner.",
            feedback="Author meta.narrative_spine deliberately in the plan instead of letting the server infer it after the fact.",
        )

    if composition_profile in {"editorial", "immersive"} and len(throughlines) < 2:
        add_finding(
            code="narrative_throughlines_thin",
            severity="medium",
            penalty=4,
            message="The plan's narrative spine does not carry enough throughlines to keep the artifact coherent across sections.",
            feedback="Add 2-4 throughlines that the opening, evidence sections, and close can all reinforce consistently.",
        )

    if composition_profile in {"editorial", "data-forward", "immersive"} and not claim_map:
        add_finding(
            code="claim_map_missing",
            severity="high",
            penalty=6,
            message="The narrative spine does not define a claim map, so the artifact lacks an explicit contract for what each section is supposed to prove or support.",
            feedback="Add narrative_spine.claim_map with concrete claims and the section indices that support each one.",
            critical_issue=composition_profile == "immersive",
        )
    elif composition_profile in {"editorial", "immersive"} and len(claim_map) < 2:
        add_finding(
            code="claim_map_thin",
            severity="medium",
            penalty=4,
            message="The claim map is too thin to anchor a multi-section artifact.",
            feedback="Define at least two non-overlapping claims or throughline-support pairs so the critic can verify argument continuity.",
        )

    if composition_profile in {"editorial", "data-forward", "immersive"} and not key_entities:
        add_finding(
            code="key_entities_missing",
            severity="medium",
            penalty=3,
            message="The narrative spine does not name any key entities or anchor phrases to keep stable across sections.",
            feedback="Add narrative_spine.key_entities for the entities, product names, metrics, or anchors that must stay consistent from start to finish.",
        )

    if composition_profile in {"editorial", "data-forward", "immersive"} and not closing_obligation:
        add_finding(
            code="closing_obligation_missing",
            severity="medium",
            penalty=4,
            message="The plan is missing a closing obligation, so the artifact may end without resolving what the reader should understand or do next.",
            feedback="Define narrative_spine.closing_obligation so the final section clearly completes the argument instead of fading out.",
        )

    if must_keep_facts == [] and structured_data and composition_profile in {"editorial", "data-forward"}:
        add_finding(
            code="must_keep_facts_missing",
            severity="medium",
            penalty=3,
            message="Structured data exists, but the narrative spine does not declare must-keep facts to anchor the story.",
            feedback="List the facts, figures, or claims that must remain stable across the whole artifact in narrative_spine.must_keep_facts.",
        )

    if composition_profile in {"editorial", "data-forward", "immersive"}:
        if len(section_job_labels) != len(section_titles):
            add_finding(
                code="section_job_coverage_gap",
                severity="medium",
                penalty=5,
                message="The narrative spine does not map a distinct section job across the full document.",
                feedback="Make narrative_spine.section_jobs cover each section in order so every section has a non-interchangeable role in the argument.",
            )
        else:
            normalized_jobs = [_normalize_title(label) for label in section_job_labels]
            generic_job_count = sum(1 for label in normalized_jobs if label in GENERIC_SECTION_JOBS)
            duplicate_jobs = sorted({label for label in normalized_jobs if label and normalized_jobs.count(label) > 1})
            if generic_job_count >= 2 or duplicate_jobs:
                add_finding(
                    code="generic_section_jobs",
                    severity="medium",
                    penalty=5,
                    message="The narrative spine uses generic or repeated section jobs, which weakens document-level coherence.",
                    feedback="Rewrite section_jobs so each one names a concrete argumentative role rather than repeating generic overview/analysis/summary language.",
                )

    if claim_map:
        invalid_support = []
        normalized_claims = [_normalize_title(str(item.get("claim") or "")) for item in claim_map]
        generic_claim_count = sum(1 for claim in normalized_claims if claim in GENERIC_CLAIM_PHRASES or claim in GENERIC_SECTION_JOBS)
        duplicate_claims = sorted({claim for claim in normalized_claims if claim and normalized_claims.count(claim) > 1})
        for item in claim_map:
            support_sections = item.get("support_sections") or []
            if not support_sections or any(index < 1 or index > len(section_titles) for index in support_sections):
                invalid_support.append(item.get("claim"))
        if invalid_support:
            add_finding(
                code="claim_map_support_gap",
                severity="medium",
                penalty=5,
                message="At least one claim-map item is missing valid supporting section indices.",
                feedback="Make every claim_map item point to the real section indices that support it so the artifact has a checkable argument structure.",
            )
        if generic_claim_count >= 2 or duplicate_claims:
            add_finding(
                code="generic_claim_map",
                severity="medium",
                penalty=4,
                message="The claim map uses generic or repeated claim language, which weakens document-level coherence.",
                feedback="Rewrite claim_map items so they name concrete, defensible claims instead of generic thesis/summary filler.",
            )

    if _is_ranking_request(prompt, template_hint, structured_data) and "44" not in engines:
        add_finding(
            code="ranking_chart_mismatch",
            severity="high",
            penalty=10,
            message="The request asked for a ranked interactive data view, but the plan does not include the interactive ranking chart engine.",
            feedback="Use engine 44 for ranking charts, league tables, market-size rankings, and sortable horizontal range-bar views instead of flattening them into generic dashboards or comparison prose.",
            critical_issue=True,
        )

    if _prompt_contains_any(lower_prompt, DASHBOARD_KEYWORDS) and "03" not in engines:
        add_finding(
            code="dashboard_mismatch",
            severity="high",
            penalty=10,
            message="The request asked for a dashboard/KPI artifact, but the plan does not include the KPI/dashboard engine.",
            feedback="Add a real KPI/dashboard section with engine 03 or an equally explicit metric surface. Do not answer a dashboard request with mostly narrative sections.",
            critical_issue=True,
        )

    if _prompt_contains_any(lower_prompt, CALCULATOR_KEYWORDS) and not ({"08", "17"} & engines):
        add_finding(
            code="calculator_mismatch",
            severity="high",
            penalty=10,
            message="The request asked for a calculator or scenario model, but the plan lacks calculator/modeling engines.",
            feedback="Add a true calculator or scenario-modeling engine when the request explicitly asks for one.",
            critical_issue=True,
        )

    if _prompt_contains_any(lower_prompt, FLOWCHART_KEYWORDS) and "06" not in engines:
        add_finding(
            code="flowchart_mismatch",
            severity="high",
            penalty=8,
            message="The request asked for a process or flowchart surface, but the plan lacks the flowchart engine.",
            feedback="Use engine 06 for workflow, process, and decision-tree requests instead of narrating the process in prose only.",
            critical_issue=True,
        )

    if _prompt_contains_any(lower_prompt, QUIZ_KEYWORDS) and "09" not in engines:
        add_finding(
            code="quiz_mismatch",
            severity="high",
            penalty=8,
            message="The request asked for a quiz or assessment, but the plan lacks the quiz engine.",
            feedback="Use engine 09 for quiz, scored assessment, and readiness-check requests.",
            critical_issue=True,
        )

    if _prompt_contains_any(lower_prompt, PRICING_KEYWORDS) and "11" not in engines:
        add_finding(
            code="pricing_mismatch",
            severity="high",
            penalty=8,
            message="The request asked for pricing or plans, but the plan lacks the pricing engine.",
            feedback="Use engine 11 as the dominant pricing/plans surface instead of burying plan comparisons in prose or generic cards.",
            critical_issue=True,
        )

    if _is_social_proof_request(prompt, template_hint, structured_data) and "39" not in engines:
        add_finding(
            code="social_proof_globe_mismatch",
            severity="high",
            penalty=8,
            message="The request asked for global or orbit-style social proof, but the plan does not include the globe testimonials engine.",
            feedback="Use engine 39 for testimonial globe, global proof, customer footprint, and rotating proof requests instead of defaulting to a generic testimonial block.",
            critical_issue=True,
        )

    if _is_social_proof_request(prompt, template_hint, structured_data) and not _prompt_contains_any(lower_prompt, SOCIAL_PROOF_METRIC_KEYWORDS) and ({"03", "34"} & engines):
        add_finding(
            code="social_proof_metric_invention_risk",
            severity="medium",
            penalty=5,
            message="The plan adds counters or metric-heavy proof even though the request did not supply or ask for adoption numbers.",
            feedback="Remove invented counters and KPI proof unless the prompt or structured data explicitly provides those numbers.",
        )

    if _is_social_proof_request(prompt, template_hint, structured_data) and not _prompt_contains_any(lower_prompt, SOCIAL_PROOF_FORM_KEYWORDS) and "16" in engines:
        add_finding(
            code="social_proof_form_scope_drift",
            severity="high",
            penalty=8,
            message="The plan adds a form or lead-capture surface even though the request did not ask for one.",
            feedback="Keep globe-proof artifacts focused. Use a lighter CTA when the prompt asks for social proof but not form capture.",
            critical_issue=True,
        )

    if _is_reveal_card_request(prompt, template_hint) and "13" not in engines:
        add_finding(
            code="reveal_card_mismatch",
            severity="critical",
            penalty=12,
            message="The request wants a reveal-card or fortune-style interaction, but the plan does not use the flashcard engine.",
            feedback="Use engine 13 as the dominant interaction surface for fortune-cookie, flashcard, and reveal-card prompts instead of substituting tabs, quizzes, comparison sliders, or generic cards.",
            critical_issue=True,
        )

    if "14" in engines and not _prompt_supports_before_after_surface(prompt, template_hint):
        add_finding(
            code="before_after_scope_drift",
            severity="critical",
            penalty=12,
            message="The plan adds a before/after comparison slider even though the request did not ask for a visual comparison surface.",
            feedback="Remove engine 14 unless the prompt explicitly asks for before/after, image comparison, or transformation storytelling.",
            critical_issue=True,
        )

    if "16" in engines and not _prompt_requests_form_capture(prompt, template_hint):
        add_finding(
            code="form_scope_drift",
            severity="critical",
            penalty=12,
            message="The plan adds a form or data-collection surface even though the request did not ask for signup, booking, feedback, or input capture.",
            feedback="Replace engine 16 with a lightweight CTA, share block, or closing note unless the prompt explicitly asks for form capture.",
            critical_issue=True,
        )

    if ({"03", "34"} & engines) and not _prompt_supports_metric_surface(prompt, template_hint, structured_data):
        add_finding(
            code="unsupported_metric_surface",
            severity="critical",
            penalty=10,
            message="The plan introduces counters or metric-heavy surfaces even though the request did not provide or ask for quantitative proof.",
            feedback="Remove KPI/counter surfaces unless the prompt or structured data explicitly supplies metrics. Use a content-led opener instead of invented stats.",
            critical_issue=True,
        )

    if _prompt_contains_any(lower_prompt, FAQ_KEYWORDS) and "22" not in engines:
        add_finding(
            code="disclosure_pattern_missing",
            severity="medium",
            penalty=4,
            message="The request appears FAQ/disclosure heavy, but the plan omits the native disclosure pattern.",
            feedback="Use a disclosure/FAQ engine instead of burying policy-like content inside long prose blocks.",
        )

    if _prompt_contains_any(lower_prompt, TIMELINE_KEYWORDS) and not ({"05", "31", "28", "37"} & engines):
        add_finding(
            code="timeline_pattern_missing",
            severity="medium",
            penalty=4,
            message="The request appears timeline-oriented, but the plan omits timeline, milestone, or roadmap surfaces.",
            feedback="Add a real chronology surface when the request is about milestones, rollouts, or phases.",
        )

    if _prompt_contains_any(lower_prompt, GANTT_KEYWORDS) and "31" not in engines:
        add_finding(
            code="gantt_mismatch",
            severity="high",
            penalty=8,
            message="The request asked for a gantt or roadmap surface, but the plan lacks the gantt/roadmap engine.",
            feedback="Use engine 31 for gantt, roadmap, and project-plan requests.",
            critical_issue=True,
        )

    if composition_profile == "minimal" and wrapper_engine in {"02", "18", "21"} and not (
        _prompt_contains_any(lower_prompt, SLIDE_REQUEST_KEYWORDS)
        or _prompt_contains_any(lower_prompt, ("tour", "walkthrough", "demo", "story", "scroll"))
    ):
        add_finding(
            code="minimal_profile_overbuilt_wrapper",
            severity="medium",
            penalty=6,
            message="The request reads like a minimal utility artifact, but the wrapper choice is much more theatrical than the job requires.",
            feedback="For minimal-profile requests, prefer no wrapper or the least intrusive wrapper possible.",
        )

    if composition_profile == "editorial" and wrapper_engine == "02" and not _prompt_contains_any(lower_prompt, SLIDE_REQUEST_KEYWORDS):
        add_finding(
            code="editorial_profile_slide_deck_overreach",
            severity="medium",
            penalty=5,
            message="The request reads as editorial, but the plan still defaulted to the slide-deck wrapper.",
            feedback="For editorial-profile requests, prefer document or scrollytelling compositions unless the user explicitly asked for slides.",
        )

    if composition_profile == "minimal" and len(plan.get("sections") or []) > 4:
        add_finding(
            code="minimal_profile_section_sprawl",
            severity="medium",
            penalty=4,
            message="The plan uses more sections than a minimal-profile request usually needs.",
            feedback="Compress minimal-profile artifacts down to the few sections that do real work.",
        )

    if composition_profile == "data-forward" and wrapper_engine in {"18", "21"} and not _prompt_contains_any(
        lower_prompt, ("story", "scroll", "narrative", "showcase", "demo", "walkthrough")
    ):
        add_finding(
            code="data_forward_wrapper_overreach",
            severity="medium",
            penalty=6,
            message="The request reads as data-forward, but the wrapper choice leans experiential instead of analytical.",
            feedback="For data-forward requests, keep the wrapper subordinate to the KPI, chart, comparison, or model surface.",
        )

    if _prompt_contains_any(lower_prompt, SLIDE_REQUEST_KEYWORDS) and wrapper_engine != "02":
        add_finding(
            code="slide_request_wrapper_mismatch",
            severity="high",
            penalty=8,
            message="The request explicitly asked for a slide or deck artifact, but the plan did not use the slide-deck wrapper.",
            feedback="Use wrapper 02 for deck-style requests unless the user explicitly asks for a different presentation mechanic.",
            critical_issue=True,
        )

    if wrapper_engine == "21" and not _prompt_contains_any(lower_prompt, TOUR_REQUEST_KEYWORDS):
        add_finding(
            code="guided_tour_misuse",
            severity="medium",
            penalty=6,
            message="The plan selected the guided-tour wrapper even though the request does not read like a walkthrough or product demo.",
            feedback="Use wrapper 21 only when the user explicitly wants a tour, walkthrough, demo, or onboarding-style artifact.",
        )

    if wrapper_engine == "24" and not _prompt_contains_any(lower_prompt, EVENT_REQUEST_KEYWORDS):
        add_finding(
            code="event_wrapper_misuse",
            severity="medium",
            penalty=6,
            message="The plan selected the event/countdown wrapper even though the request does not read like an event, invitation, or RSVP artifact.",
            feedback="Use wrapper 24 only for event-style experiences such as invitations, countdowns, webinars, launches, or RSVP pages.",
        )

    if wrapper_engine == "25" and not _prompt_contains_any(lower_prompt, CONTRACT_REQUEST_KEYWORDS):
        add_finding(
            code="contract_wrapper_misuse",
            severity="medium",
            penalty=6,
            message="The plan selected the contract/acceptance wrapper even though the request does not read like a signing or acknowledgment artifact.",
            feedback="Use wrapper 25 only when the user actually needs agreement, signature, acceptance, or policy-acknowledgment behavior.",
        )

    if uploaded_heading_count >= 4 and len(section_titles) < min(uploaded_heading_count, 4):
        add_finding(
            code="source_outline_drift",
            severity="high",
            penalty=8,
            message="The uploaded source had a richer outline than the resulting plan preserved.",
            feedback="Preserve the uploaded source outline more faithfully. Do not compress a multi-heading source into a few generic sections unless the user explicitly asked for that.",
            critical_issue=True,
        )

    if "11" in modules and uploaded_heading_count >= 3 and generic_title_count >= 2:
        add_finding(
            code="source_specificity_loss",
            severity="medium",
            penalty=5,
            message="The plan appears to flatten source-backed material into generic narrative buckets.",
            feedback="Restore source-specific section jobs and must-keep specifics from the uploaded material.",
        )

    if structured_data and not ({"03", "07", "08", "17", "26", "29", "31", "32", "35"} & engines):
        add_finding(
            code="structured_data_underused",
            severity="medium",
            penalty=5,
            message="Structured data was supplied, but the plan does not use a clearly data-shaped surface.",
            feedback="Let structured data drive the composition. Use charts, tables, comparison, or scenario engines instead of generic prose-only sections.",
        )

    if structured_data and "[placeholder]" in all_plan_text:
        add_finding(
            code="structured_data_placeholder_leak",
            severity="high",
            penalty=8,
            message="The plan still contains placeholder language even though structured data was supplied.",
            feedback="Do not use placeholder content when structured data is present. Render the real values or omit unsupported fields.",
            critical_issue=True,
        )

    comparison_supported = _prompt_contains_any(lower_prompt, COMPARISON_KEYWORDS) or bool(
        {"benchmark", "baseline", "prior", "previous", "target", "comparison", "delta"} & structured_keys
    ) or _prompt_contains_any(lower_prompt, TIME_SERIES_KEYWORDS)
    if _plan_contains_delta_like_values(plan) and not comparison_supported:
        add_finding(
            code="unsupported_delta_risk",
            severity="high",
            penalty=8,
            message="The plan appears to include comparison or delta language without a supported baseline in the request.",
            feedback="Remove fabricated deltas, growth rates, and benchmark language unless the prompt or structured data actually provides a valid comparison basis.",
            critical_issue=True,
        )

    if wrapper_engine == "02" and not _prompt_contains_any(lower_prompt, SLIDE_REQUEST_KEYWORDS) and _prompt_contains_any(lower_prompt, REPORTISH_KEYWORDS):
        add_finding(
            code="slide_deck_overuse",
            severity="medium",
            penalty=4,
            message="The plan defaulted to the slide-deck wrapper for a request that reads more like a report or analysis artifact.",
            feedback="Prefer a report, guided document, or scrollytelling wrapper for analytical or research-heavy requests unless the user explicitly asked for slides.",
        )

    if wrapper_engine == "18" and (
        _prompt_contains_any(lower_prompt, DASHBOARD_KEYWORDS)
        or _prompt_contains_any(lower_prompt, CALCULATOR_KEYWORDS)
        or _prompt_contains_any(lower_prompt, FLOWCHART_KEYWORDS)
    ) and not _prompt_contains_any(lower_prompt, ("story", "scroll", "narrative", "case study", "longform")):
        add_finding(
            code="parallax_overuse",
            severity="medium",
            penalty=6,
            message="The plan defaulted to the parallax wrapper for a request that primarily needed a tool, dashboard, or process surface.",
            feedback="Do not default tool-like or dashboard-like requests into wrapper 18 unless the user explicitly asked for a scrollytelling narrative.",
        )

    if "12" in modules and not any(token in all_plan_text for token in ("best fit", "not ideal", "what you need", "required input", "preview", "template")):
        add_finding(
            code="template_page_thinness_risk",
            severity="medium",
            penalty=4,
            message="A template landing-page-like request is missing fit, input, or preview cues that help direct visitors decide.",
            feedback="For template landing pages, explicitly cover best-fit users, required input, and preview structure instead of generic feature copy.",
        )

    if "13" in modules and _prompt_contains_any(lower_prompt, SELF_SERVE_KEYWORDS) and not any(
        token in all_plan_text for token in ("upload", "preview", "logo", "headshot", "tagline", "input", "customize")
    ):
        add_finding(
            code="self_serve_productization_gap",
            severity="medium",
            penalty=4,
            message="A self-serve/template-oriented request lacks visible input or personalization surfaces.",
            feedback="When the artifact is meant to become a reusable template, surface the minimum required inputs and preview behavior instead of treating it like a one-off artifact.",
        )

    if "14" in modules and any(word in all_plan_text for word in RISKY_CLAIM_WORDS):
        add_finding(
            code="claim_language_risk",
            severity="medium",
            penalty=5,
            message="The plan uses overly absolute or risky claim language that may overstate what the evidence supports.",
            feedback="Soften or qualify absolute claim language. Do not imply guaranteed or proven outcomes unless the request and evidence truly support that.",
        )

    should_retry = critical or score_penalty >= 12
    return {
        "score_penalty": score_penalty,
        "should_retry": should_retry,
        "critical": critical,
        "warning_count": len(findings),
        "warnings": findings,
        "feedback_lines": _dedupe_preserve_order(feedback_lines),
        "active_modules": modules,
        "composition_profile": composition_profile,
    }
